diff --git a/src/main/java/kz/konturai/parser/config/FacebookLeadCallCenterProperties.java b/src/main/java/kz/konturai/parser/config/FacebookLeadCallCenterProperties.java new file mode 100644 index 0000000..c567165 --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/FacebookLeadCallCenterProperties.java @@ -0,0 +1,22 @@ +package kz.konturai.parser.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "facebook.leads.call-center") +@Data +public class FacebookLeadCallCenterProperties { + + private boolean enabled = false; + private String baseUrl = "http://localhost:8080"; + private String customerPath = "/proxy/customer/customers"; + private String interactionPath = "/proxy/interaction/interactions"; + private String actorUser = "facebook-lead-bot"; + private String actorRole = "admin"; + private String interactionChannel = "webchat"; + private String interactionQueueId = "q_main"; + private int timeoutMs = 30000; + private String subjectPrefix = "[Facebook]"; +} diff --git a/src/main/java/kz/konturai/parser/config/FacebookLeadMongoConfig.java b/src/main/java/kz/konturai/parser/config/FacebookLeadMongoConfig.java new file mode 100644 index 0000000..2e4a8d1 --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/FacebookLeadMongoConfig.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.config; + +import kz.konturai.parser.model.FacebookLead; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; + +@Configuration +public class FacebookLeadMongoConfig { + + @Bean + public ApplicationRunner facebookLeadIndexesRunner(MongoTemplate mongoTemplate) { + return args -> mongoTemplate.indexOps(FacebookLead.class) + .ensureIndex(new Index().on("external_comment_id", Sort.Direction.ASC).unique()); + } +} diff --git a/src/main/java/kz/konturai/parser/config/FacebookLeadProperties.java b/src/main/java/kz/konturai/parser/config/FacebookLeadProperties.java new file mode 100644 index 0000000..a920e35 --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/FacebookLeadProperties.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "facebook.leads") +@Data +public class FacebookLeadProperties { + + private String accessToken; + private String pageId; + private String cron = "0 */15 * * * *"; + private String graphApiVersion = "v19.0"; + private int timeoutMs = 30000; + private int postsPageSize = 25; + private int commentsPageSize = 100; +} diff --git a/src/main/java/kz/konturai/parser/config/FacebookLeadWebClientConfig.java b/src/main/java/kz/konturai/parser/config/FacebookLeadWebClientConfig.java new file mode 100644 index 0000000..783164a --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/FacebookLeadWebClientConfig.java @@ -0,0 +1,26 @@ +package kz.konturai.parser.config; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; + +@Configuration +public class FacebookLeadWebClientConfig { + + @Bean + @Qualifier("facebookLeadWebClient") + public WebClient facebookLeadWebClient(WebClient.Builder webClientBuilder, FacebookLeadProperties properties) { + HttpClient httpClient = HttpClient.create() + .responseTimeout(Duration.ofMillis(properties.getTimeoutMs())); + + return webClientBuilder + .baseUrl("https://graph.facebook.com/" + properties.getGraphApiVersion()) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + } +} diff --git a/src/main/java/kz/konturai/parser/model/FacebookLead.java b/src/main/java/kz/konturai/parser/model/FacebookLead.java new file mode 100644 index 0000000..51323ee --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/FacebookLead.java @@ -0,0 +1,56 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "facebook_leads") +public class FacebookLead { + + @Id + private String id; + + @Indexed(unique = true) + @Field("external_comment_id") + private String externalCommentId; + + @Field("author_name") + private String authorName; + + @Field("message") + private String message; + + @Field("timestamp") + private LocalDateTime timestamp; + + @Field("call_center_customer_id") + private String callCenterCustomerId; + + @Field("call_center_interaction_id") + private String callCenterInteractionId; + + @Builder.Default + @Field("call_center_sync_status") + private String callCenterSyncStatus = "PENDING"; + + @Field("call_center_sync_error") + private String callCenterSyncError; + + @Field("call_center_synced_at") + private LocalDateTime callCenterSyncedAt; + + @Builder.Default + @Field("status") + private String status = "NEW"; +} diff --git a/src/main/java/kz/konturai/parser/repository/FacebookLeadRepository.java b/src/main/java/kz/konturai/parser/repository/FacebookLeadRepository.java new file mode 100644 index 0000000..6a0edb2 --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/FacebookLeadRepository.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.FacebookLead; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface FacebookLeadRepository extends MongoRepository { + + boolean existsByExternalCommentId(String externalCommentId); + + Optional findByExternalCommentId(String externalCommentId); + + List findByCallCenterSyncStatusInOrCallCenterSyncStatusIsNull(Collection statuses); +} diff --git a/src/main/java/kz/konturai/parser/service/FacebookLeadCallCenterSyncService.java b/src/main/java/kz/konturai/parser/service/FacebookLeadCallCenterSyncService.java new file mode 100644 index 0000000..879f33a --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/FacebookLeadCallCenterSyncService.java @@ -0,0 +1,208 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.JsonNode; +import kz.konturai.parser.config.FacebookLeadCallCenterProperties; +import kz.konturai.parser.model.FacebookLead; +import kz.konturai.parser.repository.FacebookLeadRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FacebookLeadCallCenterSyncService { + + private static final List RETRYABLE_SYNC_STATUSES = List.of("PENDING", "FAILED"); + + private final FacebookLeadCallCenterProperties properties; + private final FacebookLeadRepository facebookLeadRepository; + private final WebClient.Builder webClientBuilder; + + public void syncPendingLeads() { + if (!properties.isEnabled()) { + return; + } + + List pendingLeads = + facebookLeadRepository.findByCallCenterSyncStatusInOrCallCenterSyncStatusIsNull(RETRYABLE_SYNC_STATUSES); + if (pendingLeads.isEmpty()) { + log.info("No pending Facebook leads for call-center sync"); + return; + } + + log.info("Retrying call-center sync for {} Facebook lead(s)", pendingLeads.size()); + for (FacebookLead lead : pendingLeads) { + syncLead(lead); + } + } + + public void syncLead(FacebookLead lead) { + if (lead == null) { + return; + } + + if (!properties.isEnabled()) { + log.info("Call-center sync is disabled. Lead {} remains only in marketing-parser DB", + lead.getExternalCommentId()); + return; + } + + if (!StringUtils.hasText(properties.getBaseUrl())) { + log.warn("Call-center sync skipped for lead {}: baseUrl is not configured", lead.getExternalCommentId()); + return; + } + + FacebookLead managedLead = loadManagedLead(lead); + try { + if (!StringUtils.hasText(managedLead.getCallCenterCustomerId())) { + String customerId = createCustomer(managedLead); + managedLead.setCallCenterCustomerId(customerId); + managedLead.setCallCenterSyncStatus("CUSTOMER_CREATED"); + managedLead.setCallCenterSyncError(null); + managedLead = facebookLeadRepository.save(managedLead); + log.info("Facebook lead {} linked to call-center customer {}", + managedLead.getExternalCommentId(), customerId); + } + + if (!StringUtils.hasText(managedLead.getCallCenterInteractionId())) { + String interactionId = createInteraction(managedLead); + managedLead.setCallCenterInteractionId(interactionId); + log.info("Facebook lead {} linked to call-center interaction {}", + managedLead.getExternalCommentId(), interactionId); + } + + managedLead.setCallCenterSyncStatus("SYNCED"); + managedLead.setCallCenterSyncError(null); + managedLead.setCallCenterSyncedAt(LocalDateTime.now()); + facebookLeadRepository.save(managedLead); + + log.info("Facebook lead {} successfully synced to call-center", + managedLead.getExternalCommentId()); + } catch (Exception e) { + managedLead.setCallCenterSyncStatus("FAILED"); + managedLead.setCallCenterSyncError(shorten(e.getMessage())); + facebookLeadRepository.save(managedLead); + log.error("Failed to sync Facebook lead {} to call-center", + managedLead.getExternalCommentId(), e); + } + } + + private FacebookLead loadManagedLead(FacebookLead lead) { + if (StringUtils.hasText(lead.getId())) { + return facebookLeadRepository.findById(lead.getId()).orElse(lead); + } + if (StringUtils.hasText(lead.getExternalCommentId())) { + return facebookLeadRepository.findByExternalCommentId(lead.getExternalCommentId()).orElse(lead); + } + return lead; + } + + private String createCustomer(FacebookLead lead) { + Map payload = new LinkedHashMap<>(); + payload.put("display_name", customerDisplayName(lead)); + payload.put("phones", List.of()); + payload.put("preferred_phone", null); + payload.put("tags", List.of("facebook", "hot-lead")); + + try { + JsonNode response = callCenterClient().post() + .uri(properties.getCustomerPath()) + .header("X-User", properties.getActorUser()) + .header("X-Role", properties.getActorRole()) + .bodyValue(payload) + .retrieve() + .bodyToMono(JsonNode.class) + .block(Duration.ofMillis(properties.getTimeoutMs())); + + String customerId = response != null ? response.path("customer_id").asText(null) : null; + if (!StringUtils.hasText(customerId)) { + throw new IllegalStateException("Call-center customer response does not contain customer_id"); + } + return customerId; + } catch (WebClientResponseException e) { + log.error("Call-center customer creation failed. status={}, body={}", + e.getStatusCode().value(), e.getResponseBodyAsString()); + throw e; + } + } + + private String createInteraction(FacebookLead lead) { + if (!StringUtils.hasText(lead.getCallCenterCustomerId())) { + throw new IllegalStateException("Call-center customer must be created before interaction"); + } + + Map payload = Map.of( + "channel", properties.getInteractionChannel(), + "subject", interactionSubject(lead), + "customer_id", lead.getCallCenterCustomerId(), + "queue_id", properties.getInteractionQueueId(), + "priority", 3 + ); + + try { + JsonNode response = callCenterClient().post() + .uri(properties.getInteractionPath()) + .header("X-User", properties.getActorUser()) + .header("X-Role", properties.getActorRole()) + .bodyValue(payload) + .retrieve() + .bodyToMono(JsonNode.class) + .block(Duration.ofMillis(properties.getTimeoutMs())); + + String interactionId = response != null ? response.path("interaction_id").asText(null) : null; + if (!StringUtils.hasText(interactionId)) { + throw new IllegalStateException("Call-center interaction response does not contain interaction_id"); + } + return interactionId; + } catch (WebClientResponseException e) { + log.error("Call-center interaction creation failed. status={}, body={}", + e.getStatusCode().value(), e.getResponseBodyAsString()); + throw e; + } + } + + private WebClient callCenterClient() { + return webClientBuilder + .baseUrl(properties.getBaseUrl()) + .build(); + } + + private String customerDisplayName(FacebookLead lead) { + String authorName = StringUtils.hasText(lead.getAuthorName()) + ? lead.getAuthorName().trim() + : "Facebook lead"; + if (authorName.length() >= 2) { + return authorName; + } + return "Facebook lead " + lead.getExternalCommentId(); + } + + private String interactionSubject(FacebookLead lead) { + String prefix = StringUtils.hasText(properties.getSubjectPrefix()) + ? properties.getSubjectPrefix().trim() + : "[Facebook]"; + String message = StringUtils.hasText(lead.getMessage()) + ? lead.getMessage().trim() + : "Новый горячий лид из комментария Facebook"; + String subject = prefix + " " + message; + return subject.length() <= 255 ? subject : subject.substring(0, 252) + "..."; + } + + private String shorten(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + String normalized = value.trim().replaceAll("\\s+", " "); + return normalized.length() <= 500 ? normalized : normalized.substring(0, 497) + "..."; + } +} diff --git a/src/main/java/kz/konturai/parser/service/FacebookLeadCollectorService.java b/src/main/java/kz/konturai/parser/service/FacebookLeadCollectorService.java new file mode 100644 index 0000000..d8b3e03 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/FacebookLeadCollectorService.java @@ -0,0 +1,250 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.JsonNode; +import kz.konturai.parser.config.FacebookLeadProperties; +import kz.konturai.parser.model.FacebookLead; +import kz.konturai.parser.repository.FacebookLeadRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FacebookLeadCollectorService { + + private static final DateTimeFormatter FACEBOOK_TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ"); + + private static final List KEYWORDS = List.of( + normalizeForSearch("хочу купить"), + normalizeForSearch("напишите в лс"), + normalizeForSearch("напишете в лс"), + normalizeForSearch("цена"), + normalizeForSearch("как заказать"), + normalizeForSearch("сколько стоит") + ); + + private final FacebookLeadProperties properties; + private final FacebookLeadGraphApiClient graphApiClient; + private final FacebookLeadRepository facebookLeadRepository; + private final FacebookLeadCallCenterSyncService callCenterSyncService; + + @EventListener(ApplicationReadyEvent.class) + public void collectHotLeadsOnStartup() { + runCollection("startup"); + } + + @Scheduled(cron = "${facebook.leads.cron:0 */15 * * * *}") + public void collectHotLeadsScheduled() { + runCollection("scheduled"); + } + + private void runCollection(String trigger) { + if (!isConfigured()) { + log.warn("Facebook lead collection [{}] skipped: pageId/accessToken are not configured", trigger); + return; + } + + log.info("Starting Facebook hot lead collection [{}] for page {}. postsPageSize={}, commentsPageSize={}", + trigger, properties.getPageId(), properties.getPostsPageSize(), properties.getCommentsPageSize()); + + try { + callCenterSyncService.syncPendingLeads(); + int newLeadsCount = collectHotLeads(); + log.info("Facebook hot lead collection [{}] finished. New leads found: {}", + trigger, newLeadsCount); + } catch (Exception e) { + log.error("Facebook hot lead collection [{}] failed for page {}", trigger, properties.getPageId(), e); + } + } + + public int collectHotLeads() { + if (!isConfigured()) { + log.warn("Facebook lead collection skipped: pageId/accessToken are not configured"); + return 0; + } + + int newLeadsCount = 0; + String postsAfterCursor = null; + int postsPageNumber = 1; + + do { + log.info("Processing Facebook posts page {}. afterCursor={}", + postsPageNumber, formatCursor(postsAfterCursor)); + JsonNode postsPage = graphApiClient.fetchPostsPage(postsAfterCursor); + int pageLeadsCount = processPosts(postsPage.path("data")); + newLeadsCount += pageLeadsCount; + postsAfterCursor = extractAfterCursor(postsPage); + log.info("Processed Facebook posts page {}. New leads on page: {}. nextAfterCursor={}", + postsPageNumber, pageLeadsCount, formatCursor(postsAfterCursor)); + postsPageNumber++; + } while (StringUtils.hasText(postsAfterCursor)); + + log.info("Facebook hot lead collection summary: totalNewLeads={}", newLeadsCount); + return newLeadsCount; + } + + private int processPosts(JsonNode posts) { + if (!posts.isArray()) { + log.warn("Facebook posts payload does not contain an array of posts"); + return 0; + } + + int newLeadsCount = 0; + log.info("Facebook posts page contains {} post(s)", posts.size()); + + for (JsonNode post : posts) { + newLeadsCount += processPostComments(post); + } + + return newLeadsCount; + } + + private int processPostComments(JsonNode post) { + String postId = post.path("id").asText(null); + log.info("Processing Facebook post {} comments", StringUtils.hasText(postId) ? postId : ""); + + int newLeadsCount = saveMatchingComments(post.path("comments").path("data")); + String commentsAfterCursor = extractAfterCursor(post.path("comments")); + int commentsPageNumber = 2; + + while (StringUtils.hasText(postId) && StringUtils.hasText(commentsAfterCursor)) { + log.info("Processing extra comments page {} for post {}. afterCursor={}", + commentsPageNumber, postId, formatCursor(commentsAfterCursor)); + JsonNode commentsPage = graphApiClient.fetchCommentsPage(postId, commentsAfterCursor); + int pageLeadsCount = saveMatchingComments(commentsPage.path("data")); + newLeadsCount += pageLeadsCount; + commentsAfterCursor = extractAfterCursor(commentsPage); + log.info("Processed extra comments page {} for post {}. New leads on page: {}. nextAfterCursor={}", + commentsPageNumber, postId, pageLeadsCount, formatCursor(commentsAfterCursor)); + commentsPageNumber++; + } + + log.info("Finished processing post {}. New leads found: {}", + StringUtils.hasText(postId) ? postId : "", newLeadsCount); + return newLeadsCount; + } + + private int saveMatchingComments(JsonNode comments) { + if (!comments.isArray()) { + log.info("No comments array found in current Facebook payload chunk"); + return 0; + } + + int savedCount = 0; + log.info("Inspecting {} comment(s) for hot lead keywords", comments.size()); + + for (JsonNode comment : comments) { + String externalCommentId = comment.path("id").asText(null); + String message = comment.path("message").asText(""); + String authorName = comment.path("from").path("name").asText("Unknown"); + + if (!StringUtils.hasText(externalCommentId) || !StringUtils.hasText(message)) { + log.info("Skipping Facebook comment without id/message. commentId={}, author={}", + externalCommentId, authorName); + continue; + } + + String matchedKeyword = findMatchedKeyword(message); + if (matchedKeyword == null) { + continue; + } + + log.info("Hot Facebook comment detected. commentId={}, author={}, matchedKeyword='{}', message='{}'", + externalCommentId, authorName, matchedKeyword, shorten(message)); + + if (facebookLeadRepository.existsByExternalCommentId(externalCommentId)) { + log.info("Skipping duplicate Facebook lead. commentId={}", externalCommentId); + continue; + } + + try { + FacebookLead savedLead = facebookLeadRepository.save(FacebookLead.builder() + .externalCommentId(externalCommentId) + .authorName(authorName) + .message(message) + .timestamp(parseTimestamp(comment.path("created_time").asText(null))) + .build()); + savedCount++; + log.info("Saved new Facebook lead. commentId={}, author={}, status=NEW", + externalCommentId, authorName); + callCenterSyncService.syncLead(savedLead); + } catch (DuplicateKeyException e) { + log.info("Facebook lead already exists by unique index. commentId={}", externalCommentId); + } + } + + log.info("Finished inspecting current comment chunk. Saved new leads: {}", savedCount); + return savedCount; + } + + private String findMatchedKeyword(String message) { + String normalizedMessage = normalizeForSearch(message); + return KEYWORDS.stream() + .filter(normalizedMessage::contains) + .findFirst() + .orElse(null); + } + + private LocalDateTime parseTimestamp(String createdTime) { + if (!StringUtils.hasText(createdTime)) { + return LocalDateTime.now(); + } + + try { + return OffsetDateTime.parse(createdTime).toLocalDateTime(); + } catch (DateTimeParseException e) { + try { + return OffsetDateTime.parse(createdTime, FACEBOOK_TIMESTAMP_FORMAT).toLocalDateTime(); + } catch (DateTimeParseException ignored) { + log.warn("Failed to parse Facebook comment timestamp '{}'. Falling back to now()", createdTime); + return LocalDateTime.now(); + } + } + } + + private String extractAfterCursor(JsonNode node) { + String afterCursor = node.path("paging").path("cursors").path("after").asText(null); + return StringUtils.hasText(afterCursor) ? afterCursor : null; + } + + private boolean isConfigured() { + return StringUtils.hasText(properties.getPageId()) && StringUtils.hasText(properties.getAccessToken()); + } + + private String formatCursor(String cursor) { + return StringUtils.hasText(cursor) ? cursor : ""; + } + + private String shorten(String message) { + if (!StringUtils.hasText(message)) { + return ""; + } + return message.length() <= 120 ? message : message.substring(0, 117) + "..."; + } + + static String normalizeForSearch(String text) { + if (!StringUtils.hasText(text)) { + return ""; + } + + return text + .toLowerCase(Locale.ROOT) + .replace('ё', 'е') + .replaceAll("\\s+", " ") + .trim(); + } +} diff --git a/src/main/java/kz/konturai/parser/service/FacebookLeadGraphApiClient.java b/src/main/java/kz/konturai/parser/service/FacebookLeadGraphApiClient.java new file mode 100644 index 0000000..97459f0 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/FacebookLeadGraphApiClient.java @@ -0,0 +1,107 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.JsonNode; +import kz.konturai.parser.config.FacebookLeadProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; + +@Service +@Slf4j +public class FacebookLeadGraphApiClient { + + private final FacebookLeadProperties properties; + private final WebClient facebookLeadWebClient; + + public FacebookLeadGraphApiClient( + FacebookLeadProperties properties, + @Qualifier("facebookLeadWebClient") WebClient facebookLeadWebClient) { + this.properties = properties; + this.facebookLeadWebClient = facebookLeadWebClient; + } + + public JsonNode fetchPostsPage(String afterCursor) { + String commentsField = "comments.limit(" + properties.getCommentsPageSize() + "){id,message,from,created_time}"; + log.info("Requesting Facebook posts page. pageId={}, afterCursor={}, limit={}", + properties.getPageId(), formatCursor(afterCursor), properties.getPostsPageSize()); + + try { + JsonNode response = facebookLeadWebClient.get() + .uri(uriBuilder -> { + var builder = uriBuilder + .path("/{pageId}/posts") + .queryParam("fields", "id," + commentsField) + .queryParam("limit", properties.getPostsPageSize()) + .queryParam("access_token", properties.getAccessToken()); + + if (StringUtils.hasText(afterCursor)) { + builder.queryParam("after", afterCursor); + } + + return builder.build(properties.getPageId()); + }) + .retrieve() + .bodyToMono(JsonNode.class) + .block(Duration.ofMillis(properties.getTimeoutMs())); + + log.info("Facebook posts page received. postsCount={}, nextAfterCursor={}", + countItems(response.path("data")), extractAfterCursor(response)); + return response; + } catch (WebClientResponseException e) { + log.error("Facebook Graph API posts request failed. status={}, body={}", + e.getStatusCode().value(), e.getResponseBodyAsString()); + throw e; + } + } + + public JsonNode fetchCommentsPage(String postId, String afterCursor) { + log.info("Requesting Facebook comments page. postId={}, afterCursor={}, limit={}", + postId, formatCursor(afterCursor), properties.getCommentsPageSize()); + + try { + JsonNode response = facebookLeadWebClient.get() + .uri(uriBuilder -> { + var builder = uriBuilder + .path("/{postId}/comments") + .queryParam("fields", "id,message,from,created_time") + .queryParam("limit", properties.getCommentsPageSize()) + .queryParam("access_token", properties.getAccessToken()); + + if (StringUtils.hasText(afterCursor)) { + builder.queryParam("after", afterCursor); + } + + return builder.build(postId); + }) + .retrieve() + .bodyToMono(JsonNode.class) + .block(Duration.ofMillis(properties.getTimeoutMs())); + + log.info("Facebook comments page received. postId={}, commentsCount={}, nextAfterCursor={}", + postId, countItems(response.path("data")), extractAfterCursor(response)); + return response; + } catch (WebClientResponseException e) { + log.error("Facebook Graph API comments request failed for post {}. status={}, body={}", + postId, e.getStatusCode().value(), e.getResponseBodyAsString()); + throw e; + } + } + + private int countItems(JsonNode node) { + return node != null && node.isArray() ? node.size() : 0; + } + + private String extractAfterCursor(JsonNode node) { + String afterCursor = node.path("paging").path("cursors").path("after").asText(null); + return StringUtils.hasText(afterCursor) ? afterCursor : ""; + } + + private String formatCursor(String cursor) { + return StringUtils.hasText(cursor) ? cursor : ""; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 27fff82..d51a65b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -140,9 +140,28 @@ facebook.app.secret=1e096c34d375641b7c4d05fff64299ea facebook.page.id=918835061309575 facebook.page.token=EAAaocqgT3JoBRIdErZC0vZAleU3lrszUXzX8aSylq7sr9aFDGnxUt4cZAJGPec3uUONzEdlgwNclXyDhp77vJMTie5PcSb9CLAZAQZBMX3KR3RkmLOZChZCjgjoN58RJ6DwQqZA8N0PbW27Hdr7xGfdlobYopEL2nNbpdX1DHvMdXbGa4M3C92crZAjqyMkggCeiSjTQ8WU5FuZB8T8bQXrAYjGuZAImJF82mx9gcjqtDkxcarLxZA1M41nJpf5Q2Dx6F3tpcsuc1hcHbnhdZCn96nq8aHwGa +# Facebook Hot Leads Configuration +facebook.leads.page-id=${FACEBOOK_LEADS_PAGE_ID:122096212527158660} +facebook.leads.access-token=${FACEBOOK_LEADS_ACCESS_TOKEN:} +facebook.leads.cron=${FACEBOOK_LEADS_CRON:0 */15 * * * *} +facebook.leads.graph-api-version=${FACEBOOK_LEADS_GRAPH_API_VERSION:v19.0} +facebook.leads.timeout-ms=${FACEBOOK_LEADS_TIMEOUT_MS:30000} +facebook.leads.posts-page-size=${FACEBOOK_LEADS_POSTS_PAGE_SIZE:25} +facebook.leads.comments-page-size=${FACEBOOK_LEADS_COMMENTS_PAGE_SIZE:100} +facebook.leads.call-center.enabled=${FACEBOOK_LEADS_CALL_CENTER_ENABLED:true} +facebook.leads.call-center.base-url=${FACEBOOK_LEADS_CALL_CENTER_BASE_URL:http://localhost:8080} +facebook.leads.call-center.customer-path=${FACEBOOK_LEADS_CALL_CENTER_CUSTOMER_PATH:/proxy/customer/customers} +facebook.leads.call-center.interaction-path=${FACEBOOK_LEADS_CALL_CENTER_INTERACTION_PATH:/proxy/interaction/interactions} +facebook.leads.call-center.actor-user=${FACEBOOK_LEADS_CALL_CENTER_ACTOR_USER:facebook-lead-bot} +facebook.leads.call-center.actor-role=${FACEBOOK_LEADS_CALL_CENTER_ACTOR_ROLE:admin} +facebook.leads.call-center.interaction-channel=${FACEBOOK_LEADS_CALL_CENTER_INTERACTION_CHANNEL:webchat} +facebook.leads.call-center.interaction-queue-id=${FACEBOOK_LEADS_CALL_CENTER_INTERACTION_QUEUE_ID:q_main} +facebook.leads.call-center.timeout-ms=${FACEBOOK_LEADS_CALL_CENTER_TIMEOUT_MS:30000} +facebook.leads.call-center.subject-prefix=${FACEBOOK_LEADS_CALL_CENTER_SUBJECT_PREFIX:[Facebook]} + targeting.ai.audience-model=gpt-4o targeting.ai.max-audience-tokens=2500 targeting.ai.budget-model=gpt-4o-mini targeting.ai.max-budget-tokens=1000 targeting.ai.creatives-model=gpt-4o -targeting.ai.max-creatives-tokens=2000 \ No newline at end of file +targeting.ai.max-creatives-tokens=2000 diff --git a/src/test/java/kz/konturai/parser/service/FacebookLeadCollectorServiceTest.java b/src/test/java/kz/konturai/parser/service/FacebookLeadCollectorServiceTest.java new file mode 100644 index 0000000..42fa58e --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/FacebookLeadCollectorServiceTest.java @@ -0,0 +1,249 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.config.FacebookLeadProperties; +import kz.konturai.parser.model.FacebookLead; +import kz.konturai.parser.repository.FacebookLeadRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FacebookLeadCollectorServiceTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + private FacebookLeadGraphApiClient graphApiClient; + + @Mock + private FacebookLeadRepository facebookLeadRepository; + + @Mock + private FacebookLeadCallCenterSyncService callCenterSyncService; + + private FacebookLeadCollectorService service; + + @BeforeEach + void setUp() { + FacebookLeadProperties properties = new FacebookLeadProperties(); + properties.setPageId("page-1"); + properties.setAccessToken("token-1"); + + service = new FacebookLeadCollectorService( + properties, + graphApiClient, + facebookLeadRepository, + callCenterSyncService + ); + } + + @Test + void collectHotLeadsShouldSaveOnlyNewMatchingComments() throws Exception { + JsonNode firstPostsPage = objectMapper.readTree(""" + { + "data": [ + { + "id": "post-1", + "comments": { + "data": [ + { + "id": "comment-1", + "message": "Хочу купить, какая цена?", + "created_time": "2026-04-05T08:15:30+0000", + "from": { + "name": "Alice" + } + }, + { + "id": "comment-2", + "message": "Спасибо за пост", + "created_time": "2026-04-05T08:16:30+0000", + "from": { + "name": "Bob" + } + }, + { + "id": "comment-3", + "message": "НАПИШЕТЕ В ЛС", + "created_time": "2026-04-05T08:17:30+0000", + "from": { + "name": "Carol" + } + } + ] + } + } + ] + } + """); + + when(graphApiClient.fetchPostsPage(null)).thenReturn(firstPostsPage); + when(facebookLeadRepository.existsByExternalCommentId("comment-1")).thenReturn(false); + when(facebookLeadRepository.existsByExternalCommentId("comment-3")).thenReturn(true); + when(facebookLeadRepository.save(any(FacebookLead.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + int savedCount = service.collectHotLeads(); + + ArgumentCaptor leadCaptor = ArgumentCaptor.forClass(FacebookLead.class); + verify(facebookLeadRepository).save(leadCaptor.capture()); + + FacebookLead savedLead = leadCaptor.getValue(); + assertEquals(1, savedCount); + assertEquals("comment-1", savedLead.getExternalCommentId()); + assertEquals("Alice", savedLead.getAuthorName()); + assertEquals("Хочу купить, какая цена?", savedLead.getMessage()); + assertEquals("NEW", savedLead.getStatus()); + assertEquals(2026, savedLead.getTimestamp().getYear()); + assertEquals(4, savedLead.getTimestamp().getMonthValue()); + assertEquals(5, savedLead.getTimestamp().getDayOfMonth()); + verify(facebookLeadRepository, never()).existsByExternalCommentId("comment-2"); + verify(callCenterSyncService).syncLead(any(FacebookLead.class)); + } + + @Test + void collectHotLeadsShouldProcessNextPostsAndCommentsPages() throws Exception { + JsonNode firstPostsPage = objectMapper.readTree(""" + { + "data": [ + { + "id": "post-1", + "comments": { + "data": [ + { + "id": "comment-1", + "message": "Сколько стоит?", + "created_time": "2026-04-05T08:15:30+0000", + "from": { + "name": "Alice" + } + } + ], + "paging": { + "cursors": { + "after": "comments-cursor-1" + } + } + } + } + ], + "paging": { + "cursors": { + "after": "posts-cursor-1" + } + } + } + """); + + JsonNode secondCommentsPage = objectMapper.readTree(""" + { + "data": [ + { + "id": "comment-2", + "message": "Как заказать?", + "created_time": "2026-04-05T08:16:30+0000", + "from": { + "name": "Bob" + } + } + ] + } + """); + + JsonNode secondPostsPage = objectMapper.readTree(""" + { + "data": [ + { + "id": "post-2", + "comments": { + "data": [ + { + "id": "comment-3", + "message": "цена", + "created_time": "2026-04-05T08:17:30+0000", + "from": { + "name": "Carol" + } + } + ] + } + } + ] + } + """); + + when(graphApiClient.fetchPostsPage(null)).thenReturn(firstPostsPage); + when(graphApiClient.fetchCommentsPage("post-1", "comments-cursor-1")).thenReturn(secondCommentsPage); + when(graphApiClient.fetchPostsPage("posts-cursor-1")).thenReturn(secondPostsPage); + when(facebookLeadRepository.existsByExternalCommentId(eq("comment-1"))).thenReturn(false); + when(facebookLeadRepository.existsByExternalCommentId(eq("comment-2"))).thenReturn(false); + when(facebookLeadRepository.existsByExternalCommentId(eq("comment-3"))).thenReturn(false); + when(facebookLeadRepository.save(any(FacebookLead.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + int savedCount = service.collectHotLeads(); + + assertEquals(3, savedCount); + verify(graphApiClient).fetchPostsPage(null); + verify(graphApiClient).fetchCommentsPage("post-1", "comments-cursor-1"); + verify(graphApiClient).fetchPostsPage("posts-cursor-1"); + verify(facebookLeadRepository, times(3)).save(any(FacebookLead.class)); + verify(callCenterSyncService, times(3)).syncLead(any(FacebookLead.class)); + } + + @Test + void scheduledCollectionShouldSkipWhenCredentialsAreMissing() { + FacebookLeadProperties properties = new FacebookLeadProperties(); + FacebookLeadCollectorService unconfiguredService = + new FacebookLeadCollectorService( + properties, + graphApiClient, + facebookLeadRepository, + callCenterSyncService + ); + + unconfiguredService.collectHotLeadsScheduled(); + + verifyNoInteractions(graphApiClient); + verifyNoInteractions(facebookLeadRepository); + verifyNoInteractions(callCenterSyncService); + } + + @Test + void startupCollectionShouldRunOnceWhenApplicationIsReady() throws Exception { + JsonNode emptyPostsPage = objectMapper.readTree(""" + { + "data": [] + } + """); + + when(graphApiClient.fetchPostsPage(null)).thenReturn(emptyPostsPage); + + service.collectHotLeadsOnStartup(); + + verify(graphApiClient, times(1)).fetchPostsPage(null); + verifyNoInteractions(facebookLeadRepository); + verify(callCenterSyncService).syncPendingLeads(); + } + + @Test + void normalizeForSearchShouldLowercaseCollapseWhitespaceAndReplaceYo() { + String normalized = FacebookLeadCollectorService.normalizeForSearch(" Ёжик НАПИШЕТЕ В ЛС "); + + assertEquals("ежик напишете в лс", normalized); + assertTrue(normalized.contains("напишете в лс")); + } +}