.
This commit is contained in:
@@ -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]";
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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<FacebookLead, String> {
|
||||
|
||||
boolean existsByExternalCommentId(String externalCommentId);
|
||||
|
||||
Optional<FacebookLead> findByExternalCommentId(String externalCommentId);
|
||||
|
||||
List<FacebookLead> findByCallCenterSyncStatusInOrCallCenterSyncStatusIsNull(Collection<String> statuses);
|
||||
}
|
||||
@@ -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<String> 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<FacebookLead> 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<String, Object> 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<String, Object> 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) + "...";
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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 : "<unknown>");
|
||||
|
||||
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 : "<unknown>", 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 : "<first-page>";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 : "<none>";
|
||||
}
|
||||
|
||||
private String formatCursor(String cursor) {
|
||||
return StringUtils.hasText(cursor) ? cursor : "<first-page>";
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,25 @@ 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
|
||||
|
||||
@@ -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<FacebookLead> 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("напишете в лс"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user