This commit is contained in:
Codex
2026-04-05 20:25:09 +05:00
parent 194f8ebccd
commit 195b29976d
11 changed files with 995 additions and 1 deletions
@@ -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>";
}
}
+20 -1
View File
@@ -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
targeting.ai.max-creatives-tokens=2000
@@ -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("напишете в лс"));
}
}