fix
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package kz.konturai.parser.controller;
|
||||
|
||||
import kz.konturai.parser.service.FacebookPostingService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/facebook/webhook")
|
||||
public class FacebookWebhookController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FacebookWebhookController.class);
|
||||
|
||||
@Value("${facebook.verify.token}")
|
||||
private String verifyToken;
|
||||
|
||||
@Value("${facebook.page.access.token}")
|
||||
private String pageAccessToken;
|
||||
|
||||
private final FacebookPostingService facebookService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public FacebookWebhookController(FacebookPostingService facebookService) {
|
||||
this.facebookService = facebookService;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
// Подтверждение вебхука для Фейсбука
|
||||
@GetMapping
|
||||
public ResponseEntity<String> verifyWebhook(
|
||||
@RequestParam("hub.mode") String mode,
|
||||
@RequestParam("hub.verify_token") String token,
|
||||
@RequestParam("hub.challenge") String challenge) {
|
||||
if ("subscribe".equals(mode) && verifyToken.equals(token)) {
|
||||
return ResponseEntity.ok(challenge);
|
||||
} else {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
}
|
||||
|
||||
// Сюда приходят сообщения
|
||||
@PostMapping
|
||||
public ResponseEntity<Void> receiveEvent(@RequestBody String payload) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(payload);
|
||||
if (root.has("entry")) {
|
||||
for (JsonNode entry : root.get("entry")) {
|
||||
if (entry.has("messaging")) {
|
||||
for (JsonNode event : entry.get("messaging")) {
|
||||
if (event.has("message") && !event.get("message").has("is_echo")) {
|
||||
|
||||
// === ВОТ ЭТО САМОЕ ГЛАВНОЕ ===
|
||||
String senderId = event.get("sender").get("id").asText();
|
||||
String text = event.get("message").has("text") ? event.get("message").get("text").asText() : "";
|
||||
|
||||
// Логируем ID, чтобы ты мог его скопировать
|
||||
logger.info(">>> ТЕСТЕР НАПИСАЛ СООБЩЕНИЕ! ID (PSID): {}", senderId);
|
||||
logger.info(">>> Текст: {}", text);
|
||||
|
||||
// Можно сразу отправить автоответ для проверки связи
|
||||
facebookService.sendPrivateMessage(pageAccessToken, senderId, "ID получен: " + senderId + ". Жди уведомления о посте!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (Exception e) {
|
||||
logger.error("Error processing webhook", e);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,10 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -46,74 +49,125 @@ public class FacebookPostingService {
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Публикует пост в Facebook через Graph API
|
||||
*
|
||||
* @param accessToken Access Token пользователя
|
||||
* @param postText Текст поста
|
||||
* @param hashtags Список хештегов
|
||||
* @return ID опубликованного поста
|
||||
* @throws FacebookTokenExpiredException если токен истек
|
||||
* @throws RuntimeException если публикация не удалась
|
||||
*/
|
||||
public String postToFacebook(String accessToken, String postText, List<String> hashtags) {
|
||||
public void sendPrivateMessage(String pageAccessToken, String recipientId, String messageText) {
|
||||
try {
|
||||
// Формируем полный текст поста с хештегами
|
||||
String fullPostText = buildPostText(postText, hashtags);
|
||||
Map<String, Object> recipient = new HashMap<>();
|
||||
recipient.put("id", recipientId);
|
||||
|
||||
// Получаем ID страницы пользователя (me)
|
||||
String pageId = getPageId(accessToken);
|
||||
Map<String, String> messageContent = new HashMap<>();
|
||||
messageContent.put("text", messageText);
|
||||
|
||||
// Публикуем пост на странице
|
||||
String postId = publishPost(accessToken, pageId, fullPostText);
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("recipient", recipient);
|
||||
body.put("message", messageContent);
|
||||
body.put("messaging_type", "RESPONSE");
|
||||
|
||||
logger.info("Successfully posted to Facebook. Post ID: {}", postId);
|
||||
return postId;
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/me/messages")
|
||||
.queryParam("access_token", pageAccessToken)
|
||||
.build())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
|
||||
logger.info("Message sent successfully to user {}. Response: {}", recipientId, response);
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error posting to Facebook", e);
|
||||
throw new RuntimeException("Failed to post to Facebook", e);
|
||||
logger.error("Failed to send message: {}", e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to send message: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to hold page credentials (Page ID and Page Access Token)
|
||||
*/
|
||||
public String postToPage(String userAccessToken, String postText, List<String> hashtags) throws JsonProcessingException {
|
||||
try {
|
||||
String fullPostText = buildPostText(postText, hashtags);
|
||||
PageCredentials creds = getPageCredentials(userAccessToken);
|
||||
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/{pageId}/feed")
|
||||
.queryParam("access_token", creds.pageToken)
|
||||
.queryParam("message", fullPostText)
|
||||
.build(creds.pageId))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
String postId = jsonNode.get("id").asText();
|
||||
logger.info("Posted to Page {}. ID: {}", creds.pageId, postId);
|
||||
return postId;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) throw parseFacebookError(e);
|
||||
logger.error("Page API error: {}", e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to post to Page: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public String postToPageWithImage(String userAccessToken, String postText, List<String> hashtags,
|
||||
byte[] imageData) {
|
||||
try {
|
||||
String fullPostText = buildPostText(postText, hashtags);
|
||||
PageCredentials creds = getPageCredentials(userAccessToken);
|
||||
|
||||
ByteArrayResource imageResource = new ByteArrayResource(imageData) {
|
||||
@Override public String getFilename() { return "image.jpg"; }
|
||||
};
|
||||
|
||||
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("message", fullPostText);
|
||||
formData.add("source", imageResource);
|
||||
formData.add("access_token", creds.pageToken);
|
||||
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder.path("/{pageId}/photos").build(creds.pageId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(formData))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs * 2));
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
String postId = jsonNode.get("id").asText();
|
||||
logger.info("Posted image to Page {}. ID: {}", creds.pageId, postId);
|
||||
return postId;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) throw parseFacebookError(e);
|
||||
logger.error("Page image API error: {}", e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to post image to Page: " + e.getMessage(), e);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to post image to Page", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildPostText(String postText, List<String> hashtags) {
|
||||
StringBuilder fullText = new StringBuilder(postText != null ? postText : "");
|
||||
if (hashtags != null && !hashtags.isEmpty()) {
|
||||
if (fullText.length() > 0) fullText.append("\n\n");
|
||||
String hashtagsText = hashtags.stream()
|
||||
.map(tag -> tag.startsWith("#") ? tag : "#" + tag)
|
||||
.collect(Collectors.joining(" "));
|
||||
fullText.append(hashtagsText);
|
||||
}
|
||||
return fullText.toString();
|
||||
}
|
||||
|
||||
private static class PageCredentials {
|
||||
String pageId;
|
||||
String pageToken;
|
||||
|
||||
public PageCredentials(String pageId, String pageToken) {
|
||||
this.pageId = pageId;
|
||||
this.pageToken = pageToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Page credentials (ID and Token).
|
||||
* Handles two scenarios:
|
||||
* 1. The input is a User Token: Calls /me/accounts to find the Page and its
|
||||
* Page Token.
|
||||
* 2. The input is ALREADY a Page Token: Detects the error, fetches the Page ID
|
||||
* directly, and uses the input token.
|
||||
*
|
||||
* @param accessToken User Access Token or Page Access Token
|
||||
* @return PageCredentials с Page ID и Page Access Token
|
||||
* @throws FacebookTokenExpiredException если токен истек
|
||||
* @throws RuntimeException если не удалось получить credentials
|
||||
* страницы
|
||||
*/
|
||||
private PageCredentials getPageCredentials(String accessToken) {
|
||||
try {
|
||||
// SCENARIO 1: Try to treat it as a User Token and find pages
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/me/accounts")
|
||||
@@ -123,49 +177,28 @@ public class FacebookPostingService {
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
|
||||
JsonNode root = objectMapper.readTree(response);
|
||||
JsonNode data = root.get("data");
|
||||
JsonNode data = objectMapper.readTree(response).get("data");
|
||||
|
||||
if (data.isEmpty()) {
|
||||
throw new RuntimeException("User has no pages available. Please check permissions (pages_show_list).");
|
||||
return getPageDetailsDirectly(accessToken);
|
||||
}
|
||||
|
||||
// Return the first page found
|
||||
JsonNode firstPage = data.get(0);
|
||||
return new PageCredentials(
|
||||
firstPage.get("id").asText(),
|
||||
firstPage.get("access_token").asText());
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
// SCENARIO 2: Check if the token is already a Page Token
|
||||
// Error code 100 with message containing "node type (Page)" means we are
|
||||
// already a Page
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
|
||||
String responseBody = e.getResponseBodyAsString();
|
||||
if (e.getStatusCode().value() == 400 && responseBody != null && responseBody.contains("Page")) {
|
||||
logger.info("Provided token is already a Page Access Token. Skipping exchange.");
|
||||
if (e.getStatusCode().value() == 400 && e.getResponseBodyAsString().contains("Page")) {
|
||||
return getPageDetailsDirectly(accessToken);
|
||||
}
|
||||
|
||||
logger.error("Failed to get Facebook page credentials: {} - {}", e.getStatusCode(), responseBody);
|
||||
throw new RuntimeException("Failed to get page credentials: " + e.getMessage(), e);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("Failed to parse Facebook page credentials response", e);
|
||||
throw new RuntimeException("Failed to parse page credentials response", e);
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
throw e;
|
||||
if (isTokenExpiredError(e)) throw parseFacebookError(e);
|
||||
throw new RuntimeException("Failed to get page credentials", e);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to get page credentials", e);
|
||||
throw new RuntimeException("Failed to get page credentials: " + e.getMessage(), e);
|
||||
throw new RuntimeException("Failed to get page credentials", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method: When we already have a Page Token, we just need the Page ID.
|
||||
*/
|
||||
private PageCredentials getPageDetailsDirectly(String pageAccessToken) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
@@ -178,272 +211,32 @@ public class FacebookPostingService {
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
|
||||
JsonNode root = objectMapper.readTree(response);
|
||||
String pageId = root.get("id").asText();
|
||||
|
||||
// Return the ID we found, and reuse the token we already have
|
||||
String pageId = objectMapper.readTree(response).get("id").asText();
|
||||
return new PageCredentials(pageId, pageAccessToken);
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
logger.error("Failed to verify Page Access Token: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to verify Page Access Token: " + e.getMessage(), e);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("Failed to parse Page ID response", e);
|
||||
throw new RuntimeException("Failed to verify Page Access Token: " + e.getMessage(), e);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to verify Page Access Token", e);
|
||||
throw new RuntimeException("Failed to verify Page Access Token: " + e.getMessage(), e);
|
||||
throw new RuntimeException("Invalid token provided", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает ID страницы пользователя
|
||||
*/
|
||||
private String getPageId(String accessToken) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/me")
|
||||
.queryParam("access_token", accessToken)
|
||||
.queryParam("fields", "id")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
return jsonNode.get("id").asText();
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
logger.error("Failed to get Facebook page ID: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||
// пользователя
|
||||
return "me";
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("Failed to parse Facebook page ID response", e);
|
||||
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||
// пользователя
|
||||
return "me";
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to get Facebook page ID", e);
|
||||
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||
// пользователя
|
||||
return "me";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Публикует пост на странице Facebook
|
||||
*/
|
||||
private String publishPost(String accessToken, String pageId, String message)
|
||||
throws JsonProcessingException {
|
||||
try {
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/{pageId}/feed")
|
||||
.queryParam("access_token", accessToken)
|
||||
.queryParam("message", message)
|
||||
.build(pageId))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
return jsonNode.get("id").asText();
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to publish post: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Публикует пост в Facebook с изображением через Graph API
|
||||
*
|
||||
* @param accessToken Access Token пользователя
|
||||
* @param postText Текст поста
|
||||
* @param hashtags Список хештегов
|
||||
* @param imageData Данные изображения
|
||||
* @param imageContentType MIME тип изображения (например, "image/png")
|
||||
* @return ID опубликованного поста
|
||||
* @throws FacebookTokenExpiredException если токен истек
|
||||
* @throws RuntimeException если публикация не удалась
|
||||
*/
|
||||
public String postToFacebookWithImage(String accessToken, String postText, List<String> hashtags,
|
||||
byte[] imageData, String imageContentType) {
|
||||
try {
|
||||
// Формируем полный текст поста с хештегами
|
||||
String fullPostText = buildPostText(postText, hashtags);
|
||||
|
||||
// Получаем Page Access Token и Page ID из User Access Token
|
||||
PageCredentials creds = getPageCredentials(accessToken);
|
||||
|
||||
// Загружаем изображение и публикуем пост используя Page Token
|
||||
String postId = publishPostWithImage(creds.pageToken, creds.pageId, fullPostText, imageData,
|
||||
imageContentType);
|
||||
|
||||
logger.info("Successfully posted to Facebook with image. Post ID: {}", postId);
|
||||
return postId;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error posting to Facebook", e);
|
||||
throw new RuntimeException("Failed to post to Facebook", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Публикует пост с изображением на странице Facebook
|
||||
* Использует endpoint /photos для загрузки изображения с текстом
|
||||
*/
|
||||
private String publishPostWithImage(String accessToken, String pageId, String message,
|
||||
byte[] imageData, String imageContentType)
|
||||
throws JsonProcessingException {
|
||||
try {
|
||||
// Создаем multipart form data для загрузки изображения
|
||||
ByteArrayResource imageResource = new ByteArrayResource(imageData) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "image.jpg";
|
||||
}
|
||||
};
|
||||
|
||||
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("message", message);
|
||||
formData.add("source", imageResource);
|
||||
|
||||
String response;
|
||||
try {
|
||||
response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/{pageId}/photos")
|
||||
.queryParam("access_token", accessToken)
|
||||
.build(pageId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(formData))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs * 2)); // Увеличиваем таймаут для загрузки изображения
|
||||
} catch (WebClientResponseException e) {
|
||||
String responseBody = e.getResponseBodyAsString();
|
||||
logger.error("Facebook API error during image upload: Status {} - Response body: {}",
|
||||
e.getStatusCode(), responseBody != null ? responseBody : "No response body");
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
throw new RuntimeException("Failed to publish post with image: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
String photoId = jsonNode.get("id").asText();
|
||||
|
||||
// Возвращаем ID фото, который также является ID поста
|
||||
return photoId;
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("Failed to parse Facebook response", e);
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error publishing post with image", e);
|
||||
throw new RuntimeException("Failed to publish post with image", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует полный текст поста с хештегами
|
||||
*/
|
||||
private String buildPostText(String postText, List<String> hashtags) {
|
||||
StringBuilder fullText = new StringBuilder(postText != null ? postText : "");
|
||||
|
||||
if (hashtags != null && !hashtags.isEmpty()) {
|
||||
if (fullText.length() > 0) {
|
||||
fullText.append("\n\n");
|
||||
}
|
||||
|
||||
// Добавляем хештеги, убеждаясь что они начинаются с #
|
||||
String hashtagsText = hashtags.stream()
|
||||
.map(tag -> tag.startsWith("#") ? tag : "#" + tag)
|
||||
.collect(Collectors.joining(" "));
|
||||
|
||||
fullText.append(hashtagsText);
|
||||
}
|
||||
|
||||
return fullText.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли ошибка истечением токена
|
||||
* Facebook возвращает код 190 с error_subcode 463 для истекших токенов
|
||||
*/
|
||||
private boolean isTokenExpiredError(WebClientResponseException e) {
|
||||
if (e.getStatusCode().value() != 400) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.getStatusCode().value() != 400) return false;
|
||||
try {
|
||||
String responseBody = e.getResponseBodyAsString();
|
||||
if (responseBody == null || responseBody.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String body = e.getResponseBodyAsString();
|
||||
if (body == null) return false;
|
||||
JsonNode error = objectMapper.readTree(body).get("error");
|
||||
if (error == null) return false;
|
||||
|
||||
JsonNode errorNode = objectMapper.readTree(responseBody);
|
||||
if (!errorNode.has("error")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonNode error = errorNode.get("error");
|
||||
int errorCode = error.has("code") ? error.get("code").asInt() : 0;
|
||||
int errorSubcode = error.has("error_subcode") ? error.get("error_subcode").asInt() : 0;
|
||||
String errorType = error.has("type") ? error.get("type").asText() : "";
|
||||
|
||||
// Код 190 с error_subcode 463 означает истекший токен
|
||||
// Также проверяем тип ошибки OAuthException
|
||||
return errorCode == 190 && errorSubcode == 463 && "OAuthException".equals(errorType);
|
||||
int code = error.has("code") ? error.get("code").asInt() : 0;
|
||||
int subcode = error.has("error_subcode") ? error.get("error_subcode").asInt() : 0;
|
||||
return code == 190 && subcode == 463;
|
||||
} catch (Exception ex) {
|
||||
logger.warn("Failed to parse Facebook error response", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит ошибку Facebook API и создает исключение для истекшего токена
|
||||
*/
|
||||
private FacebookTokenExpiredException parseFacebookError(WebClientResponseException e) {
|
||||
try {
|
||||
String responseBody = e.getResponseBodyAsString();
|
||||
JsonNode errorNode = objectMapper.readTree(responseBody);
|
||||
JsonNode error = errorNode.get("error");
|
||||
|
||||
String errorMessage = error.has("message") ? error.get("message").asText() : "Access token expired";
|
||||
int errorCode = error.has("code") ? error.get("code").asInt() : 190;
|
||||
int errorSubcode = error.has("error_subcode") ? error.get("error_subcode").asInt() : 463;
|
||||
|
||||
String userMessage = "Facebook access token has expired. Please update your credentials. " +
|
||||
"Error: " + errorMessage;
|
||||
|
||||
return new FacebookTokenExpiredException(userMessage, errorMessage, errorCode, errorSubcode, e);
|
||||
} catch (Exception ex) {
|
||||
logger.warn("Failed to parse Facebook error details", ex);
|
||||
return new FacebookTokenExpiredException(
|
||||
"Facebook access token has expired. Please update your credentials.",
|
||||
"Access token expired",
|
||||
190,
|
||||
463,
|
||||
e);
|
||||
}
|
||||
return new FacebookTokenExpiredException(
|
||||
"Facebook token expired. Please re-login.",
|
||||
"Token Expired", 190, 463, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import kz.konturai.parser.model.MarketingStrategy;
|
||||
import kz.konturai.parser.model.PostingTask;
|
||||
import kz.konturai.parser.repository.MarketingStrategyRepository;
|
||||
import kz.konturai.parser.repository.PostingTaskRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
@@ -18,10 +19,13 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PostingTaskService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PostingTaskService.class);
|
||||
|
||||
private static final String TEST_RECIPIENT_ID = "ВСТАВЬ_СЮДА_ID_ИЗ_ЛОГОВ";
|
||||
|
||||
private final PostingTaskRepository taskRepository;
|
||||
private final MarketingStrategyRepository strategyRepository;
|
||||
private final SocialMediaCredentialsService credentialsService;
|
||||
@@ -30,30 +34,6 @@ public class PostingTaskService {
|
||||
private final TelegramPostingService telegramPostingService;
|
||||
private final MinIOService minIOService;
|
||||
|
||||
public PostingTaskService(
|
||||
PostingTaskRepository taskRepository,
|
||||
MarketingStrategyRepository strategyRepository,
|
||||
SocialMediaCredentialsService credentialsService,
|
||||
FacebookPostingService facebookPostingService,
|
||||
LinkedInPostingService linkedInPostingService,
|
||||
TelegramPostingService telegramPostingService,
|
||||
MinIOService minIOService) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.strategyRepository = strategyRepository;
|
||||
this.credentialsService = credentialsService;
|
||||
this.facebookPostingService = facebookPostingService;
|
||||
this.linkedInPostingService = linkedInPostingService;
|
||||
this.telegramPostingService = telegramPostingService;
|
||||
this.minIOService = minIOService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает задачи публикации из стратегии
|
||||
* Конвертирует PostCalendarItem в PostingTask
|
||||
*
|
||||
* @param strategyId ID стратегии
|
||||
* @return Список созданных задач
|
||||
*/
|
||||
public List<PostingTask> createTasksFromStrategy(String strategyId) {
|
||||
Optional<MarketingStrategy> optStrategy = strategyRepository.findById(strategyId);
|
||||
if (optStrategy.isEmpty()) {
|
||||
@@ -71,7 +51,6 @@ public class PostingTaskService {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// Проверяем наличие credentials для платформ
|
||||
List<String> platforms = strategy.getPostCalendar().stream()
|
||||
.map(MarketingStrategy.PostCalendarItem::getPlatform)
|
||||
.distinct()
|
||||
@@ -84,14 +63,12 @@ public class PostingTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, не созданы ли уже задачи для этой стратегии
|
||||
List<PostingTask> existingTasks = taskRepository.findByStrategyId(strategyId);
|
||||
if (!existingTasks.isEmpty()) {
|
||||
logger.info("Tasks already exist for strategy {}. Returning existing tasks.", strategyId);
|
||||
return existingTasks;
|
||||
}
|
||||
|
||||
// Создаем задачи из календаря постов
|
||||
List<PostingTask> tasks = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
@@ -104,24 +81,20 @@ public class PostingTaskService {
|
||||
calendarItem.getHashtags(),
|
||||
calendarItem.getPublishDate());
|
||||
|
||||
// Копируем данные об изображении
|
||||
task.setImageUrl(calendarItem.getImageUrl());
|
||||
task.setImageFilename(calendarItem.getImageFilename());
|
||||
|
||||
// Если дата публикации в прошлом, выполняем сразу
|
||||
// Группы удалили, этот блок больше не нужен, но логику создания оставляем
|
||||
if (task.getPublishDate().isBefore(now) || task.getPublishDate().isEqual(now)) {
|
||||
task.setStatus("pending");
|
||||
logger.info("Task created with past date, will be executed immediately: {}", task.getPublishDate());
|
||||
}
|
||||
|
||||
tasks.add(task);
|
||||
}
|
||||
|
||||
// Сохраняем все задачи
|
||||
List<PostingTask> savedTasks = taskRepository.saveAll(tasks);
|
||||
logger.info("Created {} posting tasks from strategy {}", savedTasks.size(), strategyId);
|
||||
|
||||
// Выполняем задачи с прошедшей датой сразу
|
||||
for (PostingTask task : savedTasks) {
|
||||
if (task.getPublishDate().isBefore(now) || task.getPublishDate().isEqual(now)) {
|
||||
if ("pending".equals(task.getStatus())) {
|
||||
@@ -133,21 +106,10 @@ public class PostingTaskService {
|
||||
return savedTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает задачи для выполнения (pending с датой <= указанной)
|
||||
*
|
||||
* @param beforeDate Максимальная дата публикации
|
||||
* @return Список задач для выполнения
|
||||
*/
|
||||
public List<PostingTask> getPendingTasks(LocalDateTime beforeDate) {
|
||||
return taskRepository.findByStatusAndPublishDateLessThanEqual("pending", beforeDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет задачу публикации
|
||||
*
|
||||
* @param taskId ID задачи
|
||||
*/
|
||||
public void executeTask(String taskId) {
|
||||
Optional<PostingTask> optTask = taskRepository.findById(taskId);
|
||||
if (optTask.isEmpty()) {
|
||||
@@ -157,85 +119,89 @@ public class PostingTaskService {
|
||||
|
||||
PostingTask task = optTask.get();
|
||||
|
||||
// Проверяем статус
|
||||
if (!"pending".equals(task.getStatus())) {
|
||||
logger.warn("Task {} is not in pending status. Current status: {}", taskId, task.getStatus());
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем статус на processing
|
||||
task.setStatus("processing");
|
||||
taskRepository.save(task);
|
||||
|
||||
try {
|
||||
// Получаем credentials
|
||||
String credentials = credentialsService.getCredentials(task.getUserId(), task.getPlatform());
|
||||
if (credentials == null || credentials.isEmpty()) {
|
||||
throw new IllegalStateException("Credentials not found for platform: " + task.getPlatform());
|
||||
}
|
||||
|
||||
// Загружаем изображение, если оно есть
|
||||
byte[] imageData = null;
|
||||
if (task.getImageFilename() != null && !task.getImageFilename().isEmpty()) {
|
||||
try {
|
||||
java.io.InputStream imageStream = minIOService.downloadFile(task.getImageFilename());
|
||||
imageData = imageStream.readAllBytes();
|
||||
logger.info("Loaded image for task {}: {} bytes", taskId, imageData.length);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to load image for task {}: {}", taskId, e.getMessage());
|
||||
// Продолжаем без изображения
|
||||
}
|
||||
}
|
||||
|
||||
// Выполняем публикацию в зависимости от платформы
|
||||
String postId = null;
|
||||
|
||||
if ("facebook".equalsIgnoreCase(task.getPlatform())) {
|
||||
|
||||
// 1. ПУБЛИКУЕМ ПОСТ НА СТРАНИЦУ
|
||||
if (imageData != null && imageData.length > 0) {
|
||||
postId = facebookPostingService.postToFacebookWithImage(
|
||||
postId = facebookPostingService.postToPageWithImage(
|
||||
credentials,
|
||||
task.getPostText(),
|
||||
task.getHashtags(),
|
||||
imageData,
|
||||
"image/png");
|
||||
imageData);
|
||||
} else {
|
||||
postId = facebookPostingService.postToFacebook(
|
||||
postId = facebookPostingService.postToPage(
|
||||
credentials,
|
||||
task.getPostText(),
|
||||
task.getHashtags());
|
||||
}
|
||||
|
||||
// 2. ОТПРАВЛЯЕМ ЛИЧНОЕ СООБЩЕНИЕ (ЕСЛИ ПОСТ УДАЛСЯ)
|
||||
if (postId != null) {
|
||||
// Используем захардкоженный ID для теста
|
||||
String targetRecipientId = TEST_RECIPIENT_ID;
|
||||
|
||||
if (targetRecipientId != null && !targetRecipientId.contains("ВСТАВЬ_СЮДА")) {
|
||||
try {
|
||||
String message = "✅ Ваш пост опубликован на Странице!\nID поста: " + postId + "\nВремя: " + LocalDateTime.now();
|
||||
|
||||
// Вызываем метод отправки, который ты добавил в FacebookPostingService
|
||||
facebookPostingService.sendPrivateMessage(credentials, targetRecipientId, message);
|
||||
|
||||
logger.info("TEST: Sent confirmation message to HARDCODED user {}", targetRecipientId);
|
||||
} catch (Exception msgEx) {
|
||||
logger.warn("Post succeeded, but failed to send private message: {}", msgEx.getMessage());
|
||||
}
|
||||
} else {
|
||||
logger.warn("TEST: Recipient ID is not configured. Please check TEST_RECIPIENT_ID constant.");
|
||||
}
|
||||
}
|
||||
|
||||
} else if ("linkedin".equalsIgnoreCase(task.getPlatform())) {
|
||||
if (imageData != null && imageData.length > 0) {
|
||||
postId = linkedInPostingService.postToLinkedInWithImage(
|
||||
credentials,
|
||||
task.getPostText(),
|
||||
task.getHashtags(),
|
||||
imageData,
|
||||
"image/png");
|
||||
credentials, task.getPostText(), task.getHashtags(), imageData, "image/png");
|
||||
} else {
|
||||
postId = linkedInPostingService.postToLinkedIn(
|
||||
credentials,
|
||||
task.getPostText(),
|
||||
task.getHashtags());
|
||||
credentials, task.getPostText(), task.getHashtags());
|
||||
}
|
||||
} else if ("telegram".equalsIgnoreCase(task.getPlatform())) {
|
||||
if (imageData != null && imageData.length > 0) {
|
||||
postId = telegramPostingService.postToTelegramWithImage(
|
||||
credentials,
|
||||
task.getPostText(),
|
||||
task.getHashtags(),
|
||||
imageData,
|
||||
"image/png");
|
||||
credentials, task.getPostText(), task.getHashtags(), imageData, "image/png");
|
||||
} else {
|
||||
postId = telegramPostingService.postToTelegram(
|
||||
credentials,
|
||||
task.getPostText(),
|
||||
task.getHashtags());
|
||||
credentials, task.getPostText(), task.getHashtags());
|
||||
}
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Platform not supported: " + task.getPlatform());
|
||||
}
|
||||
|
||||
// Обновляем статус на completed
|
||||
task.setStatus("completed");
|
||||
task.setExecutedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(null);
|
||||
@@ -244,66 +210,29 @@ public class PostingTaskService {
|
||||
logger.info("Task {} executed successfully. Post ID: {}", taskId, postId);
|
||||
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
logger.error("Failed to execute task {}: Facebook access token has expired", taskId, e);
|
||||
|
||||
// Обновляем статус на failed с понятным сообщением об истекшем токене
|
||||
task.setStatus("failed");
|
||||
task.setExecutedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(
|
||||
"Facebook access token has expired. Please update your Facebook credentials in the social media settings. "
|
||||
+
|
||||
e.getErrorMessage());
|
||||
taskRepository.save(task);
|
||||
handleError(task, "Facebook token expired: " + e.getErrorMessage());
|
||||
} catch (LinkedInTokenExpiredException e) {
|
||||
logger.error("Failed to execute task {}: LinkedIn access token has expired", taskId, e);
|
||||
|
||||
// Обновляем статус на failed с понятным сообщением об истекшем токене
|
||||
task.setStatus("failed");
|
||||
task.setExecutedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(
|
||||
"LinkedIn access token has expired. Please update your LinkedIn credentials in the social media settings. "
|
||||
+
|
||||
e.getErrorMessage());
|
||||
taskRepository.save(task);
|
||||
handleError(task, "LinkedIn token expired: " + e.getErrorMessage());
|
||||
} catch (TelegramTokenExpiredException e) {
|
||||
logger.error("Failed to execute task {}: Telegram bot token has expired", taskId, e);
|
||||
|
||||
// Обновляем статус на failed с понятным сообщением об истекшем токене
|
||||
task.setStatus("failed");
|
||||
task.setExecutedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(
|
||||
"Telegram bot token has expired or is invalid. Please update your Telegram credentials in the social media settings. "
|
||||
+
|
||||
e.getErrorMessage());
|
||||
taskRepository.save(task);
|
||||
handleError(task, "Telegram token expired: " + e.getErrorMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to execute task {}", taskId, e);
|
||||
|
||||
// Обновляем статус на failed
|
||||
task.setStatus("failed");
|
||||
task.setExecutedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(e.getMessage());
|
||||
taskRepository.save(task);
|
||||
handleError(task, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Асинхронное выполнение задачи
|
||||
*/
|
||||
private void handleError(PostingTask task, String message) {
|
||||
task.setStatus("failed");
|
||||
task.setExecutedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(message);
|
||||
taskRepository.save(task);
|
||||
}
|
||||
|
||||
@Async("reportGenerationExecutor")
|
||||
public void executeTaskAsync(String taskId) {
|
||||
executeTask(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручное выполнение задачи публикации (независимо от времени публикации)
|
||||
* Разрешает выполнение только для задач со статусом "pending" или "failed"
|
||||
*
|
||||
* @param taskId ID задачи
|
||||
* @throws IllegalArgumentException если задача не найдена
|
||||
* @throws IllegalStateException если задача уже выполнена или имеет
|
||||
* недопустимый статус
|
||||
*/
|
||||
public void executeTaskManually(String taskId) {
|
||||
Optional<PostingTask> optTask = taskRepository.findById(taskId);
|
||||
if (optTask.isEmpty()) {
|
||||
@@ -311,55 +240,31 @@ public class PostingTaskService {
|
||||
}
|
||||
|
||||
PostingTask task = optTask.get();
|
||||
|
||||
// Проверяем статус - разрешаем только pending или failed
|
||||
String status = task.getStatus();
|
||||
|
||||
if (!"pending".equals(status) && !"failed".equals(status)) {
|
||||
throw new IllegalStateException(
|
||||
"Task cannot be executed manually. Current status: " + status +
|
||||
". Only tasks with status 'pending' or 'failed' can be executed manually.");
|
||||
"Task cannot be executed manually. Current status: " + status);
|
||||
}
|
||||
|
||||
// Если задача в статусе failed, сбрасываем статус на pending для повторной
|
||||
// попытки
|
||||
if ("failed".equals(status)) {
|
||||
task.setStatus("pending");
|
||||
task.setErrorMessage(null);
|
||||
taskRepository.save(task);
|
||||
logger.info("Task {} status reset from 'failed' to 'pending' for manual execution", taskId);
|
||||
}
|
||||
|
||||
// Выполняем задачу (игнорируя время публикации)
|
||||
executeTask(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает задачи пользователя
|
||||
*
|
||||
* @param userId ID пользователя
|
||||
* @return Список задач
|
||||
*/
|
||||
public List<PostingTask> getUserTasks(String userId) {
|
||||
return taskRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает задачи стратегии
|
||||
*
|
||||
* @param strategyId ID стратегии
|
||||
* @return Список задач
|
||||
*/
|
||||
public List<PostingTask> getStrategyTasks(String strategyId) {
|
||||
return taskRepository.findByStrategyId(strategyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает задачу по ID
|
||||
*
|
||||
* @param taskId ID задачи
|
||||
* @return Optional с задачей, если найдена
|
||||
*/
|
||||
public Optional<PostingTask> getTaskById(String taskId) {
|
||||
return taskRepository.findById(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user