diff --git a/README.md b/README.md index 09036b26..71b85ab4 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,24 @@ [![Spring Boot 4.0.6](https://img.shields.io/badge/Spring%20Boot-4.0.6-brightgreen.svg?logo=springboot&logoColor=white)](https://spring.io/projects/spring-boot) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791.svg?logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7-DC382D.svg?logo=redis&logoColor=white)](https://redis.io/) +[![Apache Kafka](https://img.shields.io/badge/Apache_Kafka-7.6-231F20.svg?logo=apachekafka&logoColor=white)](https://kafka.apache.org/) [![Liquibase](https://img.shields.io/badge/Liquibase-Migrations-006699.svg?logo=liquibase&logoColor=white)](https://www.liquibase.com/) +[![WebSockets](https://img.shields.io/badge/WebSockets-STOMP-010101.svg?logo=socketdotio&logoColor=white)](https://docs.spring.io/spring-framework/reference/web/websocket.html) [![React 19](https://img.shields.io/badge/React-19-61dafb.svg?logo=react&logoColor=black)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Tailwind CSS v4](https://img.shields.io/badge/Tailwind_CSS-v4-38b2ac.svg?logo=tailwindcss&logoColor=white)](https://tailwindcss.com/) +[![TanStack Query](https://img.shields.io/badge/TanStack_Query-v5-FF4154.svg?logo=reactquery&logoColor=white)](https://tanstack.com/query) +[![Zod](https://img.shields.io/badge/Zod-v4-3E67B1.svg?logo=zod&logoColor=white)](https://zod.dev/) +[![AI Multi-Router](https://img.shields.io/badge/AI-Multi--Router-8E75B2.svg?logo=googlegemini&logoColor=white)](docs/backend/decisions/README.md#adr-002-multi-provider-ai-fallback-router-architecture) +[![Stripe API](https://img.shields.io/badge/Stripe-API-635BFF.svg?logo=stripe&logoColor=white)](https://stripe.com/) +[![Playwright](https://img.shields.io/badge/Playwright-E2E_Tests-2EAD33.svg?logo=playwright&logoColor=white)](https://playwright.dev/) +[![Grafana k6](https://img.shields.io/badge/Grafana_k6-Load_Testing-7D64FF.svg?logo=k6&logoColor=white)](https://k6.io/) [![Docker](https://img.shields.io/badge/Docker-Multi--Stage-2496ED.svg?logo=docker&logoColor=white)](https://www.docker.com/) [![Nginx](https://img.shields.io/badge/Nginx-Reverse_Proxy-009639.svg?logo=nginx&logoColor=white)](https://nginx.org/) [![Testcontainers](https://img.shields.io/badge/Testcontainers-Integration_Tests-0B1C28.svg)](https://testcontainers.com/) [![Telegram Bot API](https://img.shields.io/badge/Telegram-Bot_API-26A5E4.svg?logo=telegram&logoColor=white)](https://core.telegram.org/bots/api) +[![Cloudflare Turnstile](https://img.shields.io/badge/Cloudflare-Turnstile-F38020.svg?logo=cloudflare&logoColor=white)](https://www.cloudflare.com/products/turnstile/) --- @@ -39,8 +48,6 @@ It serves as a full-scale technical showcase of building resilient, high-concurr Launchly Platform Overview

---- - ## Tech Stack ### Backend — `backend/` @@ -53,6 +60,7 @@ It serves as a full-scale technical showcase of building resilient, high-concurr - **Transactional Outbox & DLQ**: Resilient asynchronous event delivery with retry backoff and Dead Letter Queue - **Distributed Idempotency Layer**: Redis-backed replay protection on all critical mutation endpoints - **Dynamic Tier-Based Rate Limiter**: Token-bucket algorithm with RFC headers and role-based quotas +- **Cloudflare Turnstile Verify API**: Server-side bot verification and credential stuffing protection on auth endpoints - **Enterprise Security Headers**: Strict HSTS, Content-Type Options, Frame Options, Referrer and Permissions policies - **GDPR & SOC2 PII Masking**: Automatic redacting of emails, phone numbers, and secrets in runtime logs - **JPA Deep Entity Graphs & JDBC Batching**: High-throughput statement batching (size=50) and N+1 query elimination @@ -71,6 +79,7 @@ It serves as a full-scale technical showcase of building resilient, high-concurr - **TanStack Query v5**: Server state caching, background synchronization, and optimistic mutations - **Zustand v5**: Client-only synchronous state (auth session, canvas selection, network health) - **React Hook Form + Zod v4**: Strict type-safe form validation and DTO transformations +- **Cloudflare Turnstile Widget**: Non-blocking bot protection with dynamic theme and flexible sizing - **React Router v7**: Nested dashboard layouts and role-based route guards - **STOMP & SockJS Client**: Real-time live chat subscriptions - **TanStack Virtual & Custom Virtualizer**: Scalable windowing for high-volume conversation and contact lists @@ -90,6 +99,7 @@ It serves as a full-scale technical showcase of building resilient, high-concurr - **Visual Bot Constructor**: Drag-and-drop conversational graph builder supporting Message, Menu, Action, Condition, and AI nodes. - **Omnichannel CRM & Live Inbox**: Two-way Telegram chat with live agent intervention, conversation search, and lead status pipelines. +- **Cloudflare Turnstile Bot & Spam Protection**: Smart non-interactive challenge verification securing authentication flows (registration and login) with zero friction. - **Transactional Outbox & Integrations**: Guaranteed at-least-once delivery for webhooks (Hotmart, e-commerce, CRM leads) with automatic retry backoff and DLQ tracking. - **Distributed Idempotency Protection**: Safe concurrent execution prevention for AI generation, campaign dispatches, team invites, and support appeals. - **Dynamic Tier Rate Limiting**: Multi-tenant token-bucket rate limiting per plan tier (`ROLE_OWNER`, `ROLE_PRO`, `ROLE_ENTERPRISE`, `ROLE_ADMIN`). diff --git a/backend/pom.xml b/backend/pom.xml index cebbff92..59732ea2 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -30,7 +30,7 @@ 21 1.6.3 0.12.6 - 1.20.4 + 1.20.6 polchduikt_launchly polchduikt https://sonarcloud.io @@ -101,6 +101,10 @@ org.springframework.boot spring-boot-starter-jackson + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + org.springframework.boot spring-boot-starter-websocket @@ -273,6 +277,10 @@ ${testcontainers.version} test + + com.github.ben-manes.caffeine + caffeine + @@ -297,6 +305,7 @@ org.projectlombok lombok + 1.18.36 org.mapstruct diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java index e798053b..b9d17e39 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; @@ -82,7 +83,7 @@ public ResponseEntity toggleAutomation(@Parameter(description = "Automatio @PreAuthorize("hasRole('ADMIN')") public ResponseEntity blockAutomation( @Parameter(description = "Automation ID") @PathVariable Long automationId, - @RequestBody(required = false) AdminBlockRequest request) { + @Valid @RequestBody(required = false) AdminBlockRequest request) { adminAutomationService.blockAutomation(automationId, request); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java index 8e1b649a..2dfe0b15 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; @@ -82,7 +83,7 @@ public ResponseEntity cancelBroadcast(@Parameter(description = "Broadcast @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") public ResponseEntity blockBroadcast( @Parameter(description = "Broadcast ID") @PathVariable Long broadcastId, - @RequestBody(required = false) AdminBlockRequest request) { + @Valid @RequestBody(required = false) AdminBlockRequest request) { adminBroadcastService.blockBroadcast(broadcastId, request); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java b/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java index 969cb48f..2ee2d281 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java @@ -16,7 +16,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; - import java.util.Map; @Tag(name = "Admin: Outbox & DLQ", description = "Dead letter queue inspection and event replay management") diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java b/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java index 32081577..4d2ad3e5 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java @@ -1,8 +1,9 @@ package com.launchly.admin.controller; -import com.launchly.admin.dto.CreateMessageRequest; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; +import com.launchly.support.dto.CreateMessageRequest; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; +import com.launchly.support.enums.TicketStatus; import com.launchly.admin.service.AdminSupportChatService; import com.launchly.common.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; @@ -12,6 +13,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; @@ -66,7 +68,7 @@ public ResponseEntity getTicketDetail(@Parameter(description = @PostMapping("/{id}/messages") public ResponseEntity addMessage( @Parameter(description = "Ticket ID") @PathVariable Long id, - @RequestBody CreateMessageRequest request, + @Valid @RequestBody CreateMessageRequest request, @AuthenticationPrincipal UserDetails userDetails) { return ResponseEntity.ok(adminSupportChatService.addMessage(id, request.getText(), userDetails.getUsername())); } @@ -91,7 +93,7 @@ public ResponseEntity toggleFavorite(@Parameter(description = @PatchMapping("/{id}/status") public ResponseEntity updateStatus( @Parameter(description = "Ticket ID") @PathVariable Long id, - @Parameter(description = "New status: OPEN, PENDING, CLOSED") @RequestParam(required = false) String status) { + @Parameter(description = "New status: OPEN, PENDING, CLOSED") @RequestParam(required = false) TicketStatus status) { return ResponseEntity.ok(adminSupportChatService.updateStatus(id, status)); } diff --git a/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java b/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java index 36fb2e05..ac2bf329 100644 --- a/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java +++ b/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java @@ -11,7 +11,10 @@ @Index(name = "idx_user_audit_logs_user_date", columnList = "user_id, created_at"), @Index(name = "idx_audit_logs_user_created", columnList = "user_id, created_at DESC") }) -@Data +@Getter +@Setter +@ToString(exclude = {"user"}) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor @@ -19,6 +22,7 @@ public class UserAuditLog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include private Long id; @ManyToOne(fetch = FetchType.LAZY) diff --git a/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java b/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java index ec2167ca..4f26dfbf 100644 --- a/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java +++ b/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java @@ -1,12 +1,25 @@ package com.launchly.admin.mapper; +import com.launchly.admin.dto.AdminAutomationDto; +import com.launchly.admin.dto.AdminBroadcastDetailDto; import com.launchly.admin.dto.AdminBroadcastDto; +import com.launchly.admin.dto.AdminUserDetailDto; import com.launchly.admin.dto.AdminUserDto; +import com.launchly.admin.dto.UserActivityDto; +import com.launchly.admin.dto.UserAutomationSummaryDto; +import com.launchly.admin.dto.UserBroadcastSummaryDto; +import com.launchly.admin.entity.UserAuditLog; import com.launchly.auth.entity.User; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.FlowSchema; import com.launchly.broadcast.entity.BroadcastCampaign; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; +import org.springframework.data.domain.Page; + +import java.time.LocalDateTime; +import java.util.List; @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface AdminMapper { @@ -44,7 +57,7 @@ public interface AdminMapper { @Mapping(target = "blockedAt", source = "campaign.blockedAt") @Mapping(target = "createdByEmail", expression = "java(creator != null && creator.getEmail() != null ? creator.getEmail() : defaultAuthor)") @Mapping(target = "authorName", expression = "java(creator != null && creator.getName() != null ? creator.getName() : defaultAuthor)") - @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : java.time.LocalDateTime.now())") + @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : LocalDateTime.now())") AdminBroadcastDto toBroadcastDto(BroadcastCampaign campaign, User creator, String defaultAuthor); @Mapping(target = "id", source = "campaign.id") @@ -55,17 +68,16 @@ public interface AdminMapper { @Mapping(target = "sentCount", expression = "java(campaign.getSentCount() != null ? campaign.getSentCount() : 0)") @Mapping(target = "failedCount", expression = "java(campaign.getFailedCount() != null ? campaign.getFailedCount() : 0)") @Mapping(target = "totalCount", expression = "java(campaign.getTotalCount() != null ? campaign.getTotalCount() : 0)") - @Mapping(target = "status", expression = "java(campaign.isBlocked() ? \"BLOCKED\" : (campaign.getStatus() != null ? campaign.getStatus().name() : \"DRAFT\"))") - @Mapping(target = "blocked", source = "campaign.blocked") + @Mapping(target = "status", expression = "java(campaign.getStatus() != null ? campaign.getStatus().name() : \"DRAFT\")") @Mapping(target = "blockReason", source = "campaign.blockReason") @Mapping(target = "blockedAt", source = "campaign.blockedAt") @Mapping(target = "createdByEmail", expression = "java(creator != null && creator.getEmail() != null ? creator.getEmail() : defaultAuthor)") @Mapping(target = "authorName", expression = "java(creator != null && creator.getName() != null ? creator.getName() : defaultAuthor)") @Mapping(target = "authorId", expression = "java(creator != null ? creator.getId() : null)") - @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : java.time.LocalDateTime.now())") + @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : LocalDateTime.now())") @Mapping(target = "scheduledAt", source = "campaign.scheduledAt") @Mapping(target = "activities", source = "activities") - com.launchly.admin.dto.AdminBroadcastDetailDto toBroadcastDetailDto(BroadcastCampaign campaign, User creator, String defaultAuthor, org.springframework.data.domain.Page activities); + AdminBroadcastDetailDto toBroadcastDetailDto(BroadcastCampaign campaign, User creator, String defaultAuthor, Page activities); @Mapping(target = "id", source = "flow.id") @Mapping(target = "name", expression = "java(bot != null && bot.getName() != null ? bot.getName() : \"Flow #\" + flow.getId())") @@ -79,8 +91,8 @@ public interface AdminMapper { @Mapping(target = "blockedAt", expression = "java(bot != null ? bot.getBlockedAt() : null)") @Mapping(target = "triggerCount", source = "runsCount") @Mapping(target = "errorCount", constant = "0L") - @Mapping(target = "lastExecutedAt", expression = "java(flow.getUpdatedAt() != null ? flow.getUpdatedAt() : java.time.LocalDateTime.now())") - com.launchly.admin.dto.AdminAutomationDto toAutomationDto(com.launchly.bot.entity.FlowSchema flow, com.launchly.bot.entity.Bot bot, User owner, String resolvedBotName, boolean isConnected, int runsCount); + @Mapping(target = "lastExecutedAt", expression = "java(flow.getUpdatedAt() != null ? flow.getUpdatedAt() : LocalDateTime.now())") + AdminAutomationDto toAutomationDto(FlowSchema flow, Bot bot, User owner, String resolvedBotName, boolean isConnected, int runsCount); @Mapping(target = "id", source = "log.id") @Mapping(target = "targetId", source = "log.targetId") @@ -90,7 +102,7 @@ public interface AdminMapper { @Mapping(target = "category", source = "log.category") @Mapping(target = "badge", source = "log.badge") @Mapping(target = "timestamp", source = "log.createdAt") - com.launchly.admin.dto.UserActivityDto toActivityDto(com.launchly.admin.entity.UserAuditLog log); + UserActivityDto toActivityDto(UserAuditLog log); @Mapping(target = "id", source = "flow.id") @Mapping(target = "name", expression = "java(flow.getBot() != null ? flow.getBot().getName() : \"Flow #\" + flow.getId())") @@ -98,7 +110,7 @@ public interface AdminMapper { @Mapping(target = "active", expression = "java(flow.getBot() != null && flow.getBot().isActive() && isConnected)") @Mapping(target = "triggerCount", source = "runs") @Mapping(target = "triggerType", constant = "KEYWORD") - com.launchly.admin.dto.UserAutomationSummaryDto toAutomationSummaryDto(com.launchly.bot.entity.FlowSchema flow, String resolvedBotName, boolean isConnected, int runs); + UserAutomationSummaryDto toAutomationSummaryDto(FlowSchema flow, String resolvedBotName, boolean isConnected, int runs); @Mapping(target = "id", source = "campaign.id") @Mapping(target = "name", source = "campaign.name") @@ -106,7 +118,7 @@ public interface AdminMapper { @Mapping(target = "status", expression = "java(campaign.getStatus() != null ? campaign.getStatus().name() : \"DRAFT\")") @Mapping(target = "sentCount", expression = "java(campaign.getSentCount() != null ? campaign.getSentCount() : 0)") @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt().toString() : \"\")") - com.launchly.admin.dto.UserBroadcastSummaryDto toBroadcastSummaryDto(BroadcastCampaign campaign); + UserBroadcastSummaryDto toBroadcastSummaryDto(BroadcastCampaign campaign); @Mapping(target = "id", source = "user.id") @Mapping(target = "email", source = "user.email") @@ -130,7 +142,5 @@ public interface AdminMapper { @Mapping(target = "activities", source = "activities") @Mapping(target = "automations", source = "automations") @Mapping(target = "broadcasts", source = "broadcasts") - com.launchly.admin.dto.AdminUserDetailDto toUserDetailDto(User user, long botsCount, long automationsCount, long broadcastsCount, long contactsCount, String planName, java.time.LocalDateTime lastActivity, org.springframework.data.domain.Page activities, java.util.List automations, java.util.List broadcasts); + AdminUserDetailDto toUserDetailDto(User user, long botsCount, long automationsCount, long broadcastsCount, long contactsCount, String planName, LocalDateTime lastActivity, Page activities, List automations, List broadcasts); } - - diff --git a/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java b/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java index 546f0909..0325155f 100644 --- a/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java +++ b/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java @@ -1,7 +1,8 @@ package com.launchly.admin.service; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; +import com.launchly.support.enums.TicketStatus; import org.springframework.data.domain.Page; public interface AdminSupportChatService { @@ -9,6 +10,6 @@ public interface AdminSupportChatService { SupportTicketDto getSupportTicketDetail(Long id); SupportMessageDto addMessage(Long ticketId, String text, String managerEmail); SupportTicketDto toggleFavorite(Long id); - SupportTicketDto updateStatus(Long id, String status); + SupportTicketDto updateStatus(Long id, TicketStatus status); SupportTicketDto claimTicket(Long id, String managerEmail); } diff --git a/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java b/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java index a6e95d9d..6dc08dd7 100644 --- a/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java +++ b/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java @@ -48,7 +48,7 @@ public class AdminAutomationServiceImpl implements AdminAutomationService { private final AdminPeriodResolver periodResolver; private final AdminMapper adminMapper; private final MessageUtils messageUtils; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Override @Transactional(readOnly = true) diff --git a/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java b/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java index 64a2dc86..cf515866 100644 --- a/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java +++ b/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java @@ -24,11 +24,13 @@ import com.launchly.billing.repository.SubscriptionRepository; import com.launchly.common.utils.DateTimeUtils; import com.launchly.common.utils.MathUtils; -import com.launchly.integration.entity.Integration; import com.launchly.integration.entity.IntegrationType; import com.launchly.integration.repository.IntegrationRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; @@ -39,6 +41,7 @@ import java.util.*; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class AdminStatsServiceImpl implements AdminStatsService { @@ -72,93 +75,125 @@ public class AdminStatsServiceImpl implements AdminStatsService { public AdminStatsDto getStats(String search, String period, LocalDateTime startDate, LocalDateTime endDate) { LocalDateTime resolvedEnd = endDate != null ? endDate : LocalDateTime.now(); LocalDateTime resolvedStart = periodResolver.resolve(period, startDate); + LocalDateTime previousStart = resolvedStart.minus(Duration.between(resolvedStart.atZone(ZoneOffset.UTC), resolvedEnd.atZone(ZoneOffset.UTC))); - List allUsers = userQueryService.findAllUsers(); - List allBots = botRepository.findAll(); - List allBotUsers = botUserRepository.findAll(); - List allSchemas = flowSchemaRepository.findAll(); - List allBroadcasts = broadcastCampaignRepository.findAll(); + long totalUsers; + long totalOwners; + long activeOwners; + long totalBotUsers; + long activeBots; + long totalAutomations; + long totalMessagesSent; + long activeManagers; + + long prevOwners; + long prevBotUsersCount; + long prevActiveBots; + long prevAutomations; + long prevMessages; + + List rangeUsers; + List rangeBots; + List rangeBotUsers; + List rangeSchemas; + List rangeBroadcasts; + + long totalOwnersCount; + boolean hasBots; if (search != null && !search.trim().isEmpty()) { String q = search.trim().toLowerCase(); - allUsers = allUsers.stream() + List allUsers = userQueryService.findAllUsers().stream() .filter(u -> AdminStatsCalculator.contains(u.getName(), q) || AdminStatsCalculator.contains(u.getEmail(), q) || AdminStatsCalculator.contains(u.getTelegramUsername(), q)) .collect(Collectors.toList()); - allBots = allBots.stream() + List allBots = botRepository.findAll().stream() .filter(b -> AdminStatsCalculator.contains(b.getName(), q) || AdminStatsCalculator.contains(b.getUsername(), q)) .collect(Collectors.toList()); - allSchemas = allSchemas.stream() + List allBotUsers = botUserRepository.findAll(); + List allSchemas = flowSchemaRepository.findAll().stream() .filter(s -> s.getBot() != null && AdminStatsCalculator.contains(s.getBot().getName(), q)) .collect(Collectors.toList()); - allBroadcasts = allBroadcasts.stream() + List allBroadcasts = broadcastCampaignRepository.findAll().stream() .filter(bc -> AdminStatsCalculator.contains(bc.getName(), q)) .collect(Collectors.toList()); - } - - final LocalDateTime finalStart = resolvedStart; - final LocalDateTime finalEnd = resolvedEnd; - - List rangeUsers = DateTimeUtils.filterByDateRange(allUsers, u -> u.getCreatedAt(), finalStart, finalEnd); - List rangeBots = DateTimeUtils.filterByDateRange(allBots, b -> b.getCreatedAt(), finalStart, finalEnd); - List rangeBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, bu -> bu.getCreatedAt(), finalStart, finalEnd); - List rangeSchemas = DateTimeUtils.filterByDateRange(allSchemas, s -> s.getCreatedAt(), finalStart, finalEnd); - List rangeBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, bc -> bc.getCreatedAt(), finalStart, finalEnd); - - long totalUsers = rangeUsers.size(); - long totalOwners = rangeUsers.stream() - .filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN) - .count(); - - LocalDateTime fifteenMinsAgo = LocalDateTime.now().minusMinutes(15); - long activeOwners = allUsers.stream() - .filter(u -> u.isActive() && u.getUpdatedAt() != null && u.getUpdatedAt().isAfter(fifteenMinsAgo)) - .count(); - long totalBotUsers = rangeBotUsers.size(); - long activeBots = rangeBots.stream().filter(Bot::isActive).count(); - long totalAutomations = rangeSchemas.size(); - - long totalMessagesSent = rangeBroadcasts.stream() - .mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0) - .sum(); - - long activeManagers = rangeUsers.stream() - .filter(u -> u.getRole() == Role.ROLE_MANAGER) - .count(); + rangeUsers = DateTimeUtils.filterByDateRange(allUsers, User::getCreatedAt, resolvedStart, resolvedEnd); + rangeBots = DateTimeUtils.filterByDateRange(allBots, Bot::getCreatedAt, resolvedStart, resolvedEnd); + rangeBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, BotUser::getCreatedAt, resolvedStart, resolvedEnd); + rangeSchemas = DateTimeUtils.filterByDateRange(allSchemas, FlowSchema::getCreatedAt, resolvedStart, resolvedEnd); + rangeBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, BroadcastCampaign::getCreatedAt, resolvedStart, resolvedEnd); + + totalUsers = rangeUsers.size(); + totalOwners = rangeUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count(); + LocalDateTime fifteenMinsAgo = LocalDateTime.now().minusMinutes(15); + activeOwners = allUsers.stream().filter(u -> u.isActive() && u.getUpdatedAt() != null && u.getUpdatedAt().isAfter(fifteenMinsAgo)).count(); + totalBotUsers = rangeBotUsers.size(); + activeBots = rangeBots.stream().filter(Bot::isActive).count(); + totalAutomations = rangeSchemas.size(); + totalMessagesSent = rangeBroadcasts.stream().mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0).sum(); + activeManagers = rangeUsers.stream().filter(u -> u.getRole() == Role.ROLE_MANAGER).count(); + + List prevUsers = DateTimeUtils.filterByDateRange(allUsers, User::getCreatedAt, previousStart, resolvedStart); + List prevBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, BotUser::getCreatedAt, previousStart, resolvedStart); + List prevBots = DateTimeUtils.filterByDateRange(allBots, Bot::getCreatedAt, previousStart, resolvedStart); + List prevSchemas = DateTimeUtils.filterByDateRange(allSchemas, FlowSchema::getCreatedAt, previousStart, resolvedStart); + List prevBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, BroadcastCampaign::getCreatedAt, previousStart, resolvedStart); + + prevOwners = prevUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count(); + prevBotUsersCount = prevBotUsers.size(); + prevActiveBots = prevBots.stream().filter(Bot::isActive).count(); + prevAutomations = prevSchemas.size(); + prevMessages = prevBroadcasts.stream().mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0).sum(); + + totalOwnersCount = allUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count(); + hasBots = !allBots.isEmpty(); + } else { + totalUsers = userQueryService.countByCreatedAtBetween(resolvedStart, resolvedEnd); + totalOwners = userQueryService.countByRoleInAndCreatedAtBetween(List.of(Role.ROLE_OWNER, Role.ROLE_ADMIN), resolvedStart, resolvedEnd); + LocalDateTime fifteenMinsAgo = LocalDateTime.now().minusMinutes(15); + activeOwners = userQueryService.countByActiveTrueAndUpdatedAtAfter(fifteenMinsAgo); + totalBotUsers = botUserRepository.countByCreatedAtBetween(resolvedStart, resolvedEnd); + activeBots = botRepository.countByActiveTrueAndCreatedAtBetween(resolvedStart, resolvedEnd); + totalAutomations = flowSchemaRepository.countByCreatedAtBetween(resolvedStart, resolvedEnd); + totalMessagesSent = broadcastCampaignRepository.sumSentCountByCreatedAtBetween(resolvedStart, resolvedEnd); + activeManagers = userQueryService.countByRoleAndCreatedAtBetween(Role.ROLE_MANAGER, resolvedStart, resolvedEnd); + + prevOwners = userQueryService.countByRoleInAndCreatedAtBetween(List.of(Role.ROLE_OWNER, Role.ROLE_ADMIN), previousStart, resolvedStart); + prevBotUsersCount = botUserRepository.countByCreatedAtBetween(previousStart, resolvedStart); + prevActiveBots = botRepository.countByActiveTrueAndCreatedAtBetween(previousStart, resolvedStart); + prevAutomations = flowSchemaRepository.countByCreatedAtBetween(previousStart, resolvedStart); + prevMessages = broadcastCampaignRepository.sumSentCountByCreatedAtBetween(previousStart, resolvedStart); + + rangeUsers = userQueryService.findByCreatedAtBetween(resolvedStart, resolvedEnd); + rangeBots = botRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd); + rangeBotUsers = botUserRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd); + rangeSchemas = flowSchemaRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd); + rangeBroadcasts = broadcastCampaignRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd); + + totalOwnersCount = userQueryService.countByRoleIn(List.of(Role.ROLE_OWNER, Role.ROLE_ADMIN)); + hasBots = botRepository.count() > 0; + } long uptimeSeconds = (System.currentTimeMillis() - START_TIME) / 1000; List growth = buildGrowthMetrics( - finalStart, finalEnd, rangeUsers, rangeBotUsers, rangeBots, rangeSchemas, rangeBroadcasts, activeOwners); + resolvedStart, resolvedEnd, rangeUsers, rangeBotUsers, rangeBots, rangeSchemas, rangeBroadcasts, activeOwners); - AdminStatsDto.ServerHealthDto serverHealth = buildServerHealth(activeBots, allBots); + AdminStatsDto.ServerHealthDto serverHealth = buildServerHealth(activeBots, hasBots); - List allSubscriptions = subscriptionRepository.findAll(); + List allSubscriptions = subscriptionRepository.findAllWithPlanAndUser(); double mrrVal = AdminStatsCalculator.calculateMrr(allSubscriptions); double ltvVal = AdminStatsCalculator.calculateLtv(allSubscriptions); - LocalDateTime previousStart = resolvedStart.minus(Duration.between(resolvedStart.atZone(ZoneOffset.UTC), resolvedEnd.atZone(ZoneOffset.UTC))); - List prevUsers = DateTimeUtils.filterByDateRange(allUsers, u -> u.getCreatedAt(), previousStart, finalStart); - List prevBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, bu -> bu.getCreatedAt(), previousStart, finalStart); - List prevBots = DateTimeUtils.filterByDateRange(allBots, b -> b.getCreatedAt(), previousStart, finalStart); - List prevSchemas = DateTimeUtils.filterByDateRange(allSchemas, s -> s.getCreatedAt(), previousStart, finalStart); - List prevBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, bc -> bc.getCreatedAt(), previousStart, finalStart); - - long prevOwners = prevUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count(); - long prevBotUsersCount = prevBotUsers.size(); - long prevActiveBots = prevBots.stream().filter(Bot::isActive).count(); - long prevAutomations = prevSchemas.size(); - long prevMessages = prevBroadcasts.stream().mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0).sum(); - - List planDistribution = buildPlanDistribution(allUsers, allSubscriptions); - List integrationsPopularity = buildIntegrationsPopularity(previousStart, finalStart); - List geographyAndLanguages = buildGeography(allBotUsers, previousStart, finalStart); + List planDistribution = buildPlanDistribution(totalOwnersCount, allSubscriptions); + List integrationsPopularity = buildIntegrationsPopularity(resolvedStart); + List geographyAndLanguages = buildGeography(resolvedStart); List latestLogs = adminLogService.getSystemLogs(null, null, null, null, null, "desc", 0, 10).getContent(); List performanceMetrics = AdminStatsCalculator.buildPerformanceMetrics(); - org.springframework.security.core.Authentication auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); boolean isManagerUser = auth != null && auth.getAuthorities().stream() .anyMatch(a -> a.getAuthority().equals("ROLE_MANAGER")); @@ -275,12 +310,13 @@ private List buildGrowthMetrics( return growth; } - private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, List allBots) { + private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, boolean hasBots) { boolean dbHealthy = true; String dbStatus = "Connected"; try { userQueryService.countTotalUsers(); } catch (Exception e) { + log.warn("Database health check ping failed: {}", e.getMessage()); dbHealthy = false; dbStatus = "Error"; } @@ -289,7 +325,7 @@ private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, List 0 ? "Polling Active" : "Idle") - .telegramHealthy(activeBots > 0 || !allBots.isEmpty()) + .telegramHealthy(activeBots > 0 || hasBots) .aiStatus("Operational") .aiHealthy(true) .broadcastStatus("Ready") @@ -297,11 +333,7 @@ private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, List buildPlanDistribution(List allUsers, List allSubscriptions) { - long totalOwnersCount = allUsers.stream() - .filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN) - .count(); - + private List buildPlanDistribution(long totalOwnersCount, List allSubscriptions) { Map subCountsByPlan = allSubscriptions.stream() .filter(sub -> sub.getStatus() == SubscriptionStatus.ACTIVE && sub.getPlan() != null) .collect(Collectors.groupingBy(sub -> sub.getPlan().getDisplayName(), Collectors.counting())); @@ -319,7 +351,7 @@ private List buildPlanDistribution(List for (Plan plan : allPlans) { String displayName = plan.getDisplayName(); long count = subCountsByPlan.getOrDefault(displayName, 0L); - if (!displayName.equalsIgnoreCase("Free") && (count > 0 || true)) { + if (!displayName.equalsIgnoreCase("Free")) { String color = AdminStatsCalculator.resolvePlanColor(displayName); distribution.add(new AdminStatsDto.PlanDistributionDto(displayName, count, color)); } @@ -327,18 +359,24 @@ private List buildPlanDistribution(List return distribution; } - private List buildIntegrationsPopularity(LocalDateTime prevStart, LocalDateTime currentStart) { - List allIntegrations = integrationRepository.findAll(); - long totalCount = allIntegrations.size(); + private List buildIntegrationsPopularity(LocalDateTime currentStart) { + long totalCount = integrationRepository.count(); - Map currentCounts = allIntegrations.stream() - .collect(Collectors.groupingBy(Integration::getType, Collectors.counting())); + Map currentCounts = new EnumMap<>(IntegrationType.class); + List currentGrouped = integrationRepository.countGroupedByType(); + for (Object[] row : currentGrouped) { + IntegrationType type = (IntegrationType) row[0]; + Long count = ((Number) row[1]).longValue(); + currentCounts.put(type, count); + } - List prevIntegrations = allIntegrations.stream() - .filter(i -> i.getCreatedAt() != null && i.getCreatedAt().isBefore(currentStart)) - .collect(Collectors.toList()); - Map prevCounts = prevIntegrations.stream() - .collect(Collectors.groupingBy(Integration::getType, Collectors.counting())); + Map prevCounts = new EnumMap<>(IntegrationType.class); + List prevGrouped = integrationRepository.countGroupedByTypeAndCreatedAtBefore(currentStart); + for (Object[] row : prevGrouped) { + IntegrationType type = (IntegrationType) row[0]; + Long count = ((Number) row[1]).longValue(); + prevCounts.put(type, count); + } List result = new ArrayList<>(); @@ -355,22 +393,21 @@ private List buildIntegrationsPopularity return result; } - private List buildGeography(List allBotUsers, LocalDateTime prevStart, LocalDateTime currentStart) { - long totalBotUsersCount = allBotUsers.size(); + private List buildGeography(LocalDateTime currentStart) { + long totalBotUsersCount = botUserRepository.count(); Map currentLangCounts = new HashMap<>(); - for (BotUser bu : allBotUsers) { - String region = AdminStatsCalculator.langToRegion(AdminStatsCalculator.parseLang(bu.getMetadata())); - currentLangCounts.merge(region, 1L, Long::sum); - } - - List prevBotUsers = allBotUsers.stream() - .filter(bu -> bu.getCreatedAt() != null && bu.getCreatedAt().isBefore(currentStart)) - .collect(Collectors.toList()); Map prevLangCounts = new HashMap<>(); - for (BotUser bu : prevBotUsers) { - String region = AdminStatsCalculator.langToRegion(AdminStatsCalculator.parseLang(bu.getMetadata())); - prevLangCounts.merge(region, 1L, Long::sum); + + List metadataList = botUserRepository.findAllMetadataAndCreatedAt(); + for (Object[] row : metadataList) { + String metadata = (String) row[0]; + LocalDateTime createdAt = (LocalDateTime) row[1]; + String region = AdminStatsCalculator.langToRegion(AdminStatsCalculator.parseLang(metadata)); + currentLangCounts.merge(region, 1L, Long::sum); + if (createdAt != null && createdAt.isBefore(currentStart)) { + prevLangCounts.merge(region, 1L, Long::sum); + } } String[] regions = {"Ukraine", "United States", "Poland", "Other"}; diff --git a/backend/src/main/java/com/launchly/admin/service/impl/AdminSupportChatServiceImpl.java b/backend/src/main/java/com/launchly/admin/service/impl/AdminSupportChatServiceImpl.java index bc4400d2..6cb80f0d 100644 --- a/backend/src/main/java/com/launchly/admin/service/impl/AdminSupportChatServiceImpl.java +++ b/backend/src/main/java/com/launchly/admin/service/impl/AdminSupportChatServiceImpl.java @@ -1,11 +1,13 @@ package com.launchly.admin.service.impl; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; -import com.launchly.admin.entity.SupportMessage; -import com.launchly.admin.entity.SupportTicket; -import com.launchly.admin.repository.SupportMessageRepository; -import com.launchly.admin.repository.SupportTicketRepository; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; +import com.launchly.admin.util.AdminSupportSpecUtils; +import com.launchly.support.entity.SupportMessage; +import com.launchly.support.entity.SupportTicket; +import com.launchly.support.enums.TicketStatus; +import com.launchly.support.repository.SupportMessageRepository; +import com.launchly.support.repository.SupportTicketRepository; import com.launchly.admin.service.AdminSupportChatService; import com.launchly.auth.entity.User; import com.launchly.auth.service.UserQueryService; @@ -27,6 +29,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -46,15 +49,15 @@ public class AdminSupportChatServiceImpl implements AdminSupportChatService { private final MessageUtils messageUtils; @Override - @Transactional + @Transactional(readOnly = true) public Page getSupportTickets(String filter, String period, String search, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by("updatedAt").descending()); - Specification spec = com.launchly.admin.util.AdminSupportSpecUtils.buildTicketSpec(filter, period, search); + Specification spec = AdminSupportSpecUtils.buildTicketSpec(filter, period, search); Page ticketsPage = supportTicketRepository.findAll(spec, pageable); List dtos = ticketsPage.getContent().stream() - .map(this::mapToDto) + .map(t -> mapToDto(t, false)) .collect(Collectors.toList()); return new PageImpl<>(dtos, pageable, ticketsPage.getTotalElements()); @@ -70,7 +73,7 @@ public SupportTicketDto getSupportTicketDetail(Long id) { supportTicketRepository.save(ticket); } - return mapToDto(ticket); + return mapToDto(ticket, true); } @Override @@ -121,9 +124,9 @@ public SupportTicketDto toggleFavorite(Long id) { @Override @Transactional - public SupportTicketDto updateStatus(Long id, String targetStatus) { + public SupportTicketDto updateStatus(Long id, TicketStatus targetStatus) { SupportTicket ticket = findTicketOrThrow(id); - String statusToSet = (targetStatus != null && !targetStatus.isBlank()) ? targetStatus.toUpperCase() : "RESOLVED"; + String statusToSet = (targetStatus != null) ? targetStatus.name() : "RESOLVED"; ticket.setStatus(statusToSet); ticket.setUpdatedAt(LocalDateTime.now()); supportTicketRepository.save(ticket); @@ -170,6 +173,10 @@ private SupportTicket findTicketOrThrow(Long id) { } private SupportTicketDto mapToDto(SupportTicket ticket) { + return mapToDto(ticket, true); + } + + private SupportTicketDto mapToDto(SupportTicket ticket, boolean detailed) { User u = ticket.getUser(); String planName = "FREE"; @@ -180,21 +187,27 @@ private SupportTicketDto mapToDto(SupportTicket ticket) { .orElse("FREE"); } - long botsCount = u != null ? botRepository.countByUserId(u.getId()) : 0; - long automationsCount = u != null ? flowSchemaRepository.countByUserId(u.getId()) : 0; - long broadcastsCount = u != null ? broadcastCampaignRepository.countByUserId(u.getId()) : 0; + long botsCount = 0; + long automationsCount = 0; + long broadcastsCount = 0; long contactsCount = 0; - if (u != null) { - List botIds = botRepository.findByUserId(u.getId()).stream() - .map(b -> b.getId()) - .collect(Collectors.toList()); - if (!botIds.isEmpty()) { - contactsCount = botUserRepository.countByBotIdIn(botIds); + List messageDtos = Collections.emptyList(); + + if (detailed) { + botsCount = u != null ? botRepository.countByUserId(u.getId()) : 0; + automationsCount = u != null ? flowSchemaRepository.countByUserId(u.getId()) : 0; + broadcastsCount = u != null ? broadcastCampaignRepository.countByUserId(u.getId()) : 0; + if (u != null) { + List botIds = botRepository.findByUserId(u.getId()).stream() + .map(b -> b.getId()) + .collect(Collectors.toList()); + if (!botIds.isEmpty()) { + contactsCount = botUserRepository.countByBotIdIn(botIds); + } } + messageDtos = supportMapper.toMessageDtoList(ticket.getMessages()); } - List messageDtos = supportMapper.toMessageDtoList(ticket.getMessages()); - User mgr = ticket.getAssignedManager(); return SupportTicketDto.builder() diff --git a/backend/src/main/java/com/launchly/admin/util/AdminFilterUtils.java b/backend/src/main/java/com/launchly/admin/util/AdminFilterUtils.java index 9b87c4a8..b9d137f5 100644 --- a/backend/src/main/java/com/launchly/admin/util/AdminFilterUtils.java +++ b/backend/src/main/java/com/launchly/admin/util/AdminFilterUtils.java @@ -4,6 +4,7 @@ import com.launchly.auth.entity.Role; import com.launchly.auth.entity.User; import java.util.List; +import java.util.Map; public final class AdminFilterUtils { @@ -50,15 +51,15 @@ public static int countIntegrations(List nodeArray) { if (nodeArray == null) return 0; int count = 0; for (Object nodeObj : nodeArray) { - if (!(nodeObj instanceof java.util.Map nodeMap)) continue; + if (!(nodeObj instanceof Map nodeMap)) continue; String typeStr = nodeMap.get("type") != null ? nodeMap.get("type").toString().toLowerCase() : ""; if ("ai".equals(typeStr) || "api_call".equals(typeStr) || "google_sheets".equals(typeStr) || "webhook".equals(typeStr) || "integration".equals(typeStr)) { count++; continue; } - if (nodeMap.get("data") instanceof java.util.Map dataMap && dataMap.get("actions") instanceof List actionsList) { + if (nodeMap.get("data") instanceof Map dataMap && dataMap.get("actions") instanceof List actionsList) { for (Object act : actionsList) { - if (act instanceof java.util.Map actMap && actMap.get("type") != null) { + if (act instanceof Map actMap && actMap.get("type") != null) { String actTypeStr = actMap.get("type").toString().toUpperCase(); if (actTypeStr.startsWith("GS_") || actTypeStr.startsWith("WEBHOOK") || actTypeStr.startsWith("INTEGRATION")) { count++; diff --git a/backend/src/main/java/com/launchly/admin/util/AdminSupportSpecUtils.java b/backend/src/main/java/com/launchly/admin/util/AdminSupportSpecUtils.java index 71cd2c93..12a597b7 100644 --- a/backend/src/main/java/com/launchly/admin/util/AdminSupportSpecUtils.java +++ b/backend/src/main/java/com/launchly/admin/util/AdminSupportSpecUtils.java @@ -1,6 +1,6 @@ package com.launchly.admin.util; -import com.launchly.admin.entity.SupportTicket; +import com.launchly.support.entity.SupportTicket; import com.launchly.auth.entity.User; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.Predicate; diff --git a/backend/src/main/java/com/launchly/admin/validator/BotTokenValidator.java b/backend/src/main/java/com/launchly/admin/validator/BotTokenValidator.java index 78ee1d15..e3bb37ce 100644 --- a/backend/src/main/java/com/launchly/admin/validator/BotTokenValidator.java +++ b/backend/src/main/java/com/launchly/admin/validator/BotTokenValidator.java @@ -1,5 +1,6 @@ package com.launchly.admin.validator; +import com.launchly.bot.constant.BotConstants; import com.launchly.bot.entity.Bot; import com.launchly.common.utils.EncryptionUtil; import lombok.RequiredArgsConstructor; @@ -9,8 +10,6 @@ @RequiredArgsConstructor public class BotTokenValidator { - private static final String DUMMY_TOKEN = "0000000000:dummyTokenPlaceholderForNoBotConfig"; - private final EncryptionUtil encryptionUtil; public boolean isConnected(Bot bot) { @@ -19,7 +18,7 @@ public boolean isConnected(Bot bot) { if (rawToken == null || rawToken.isBlank()) return false; try { String decrypted = encryptionUtil.decrypt(rawToken); - return decrypted != null && !decrypted.isBlank() && !DUMMY_TOKEN.equals(decrypted); + return decrypted != null && !decrypted.isBlank() && !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(decrypted); } catch (Exception e) { return false; } diff --git a/backend/src/main/java/com/launchly/ai/client/GeminiClient.java b/backend/src/main/java/com/launchly/ai/client/GeminiClient.java index 6f278e5e..0cfccd21 100644 --- a/backend/src/main/java/com/launchly/ai/client/GeminiClient.java +++ b/backend/src/main/java/com/launchly/ai/client/GeminiClient.java @@ -112,7 +112,7 @@ public String chat(List messages, Map responseFormat, log.info("Sending chat request to Gemini model: {}", model); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Gemini API error. Status: {}, Body: {}", response.statusCode(), response.body()); throw new AppException(HttpStatus.BAD_GATEWAY, "ai.error.provider_failed"); } diff --git a/backend/src/main/java/com/launchly/ai/client/OpenAiCompatibleAiClient.java b/backend/src/main/java/com/launchly/ai/client/OpenAiCompatibleAiClient.java index 00691640..8668d324 100644 --- a/backend/src/main/java/com/launchly/ai/client/OpenAiCompatibleAiClient.java +++ b/backend/src/main/java/com/launchly/ai/client/OpenAiCompatibleAiClient.java @@ -78,7 +78,7 @@ public String chat(List messages, Map responseFormat, log.info("Sending chat request to {} model: {}", name(), model()); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("{} API error. Status: {}, Body: {}", name(), response.statusCode(), response.body()); throw new AppException(HttpStatus.BAD_GATEWAY, "ai.error.provider_failed"); } diff --git a/backend/src/main/java/com/launchly/ai/controller/AiController.java b/backend/src/main/java/com/launchly/ai/controller/AiController.java index bfb7ddeb..a5df0f7a 100644 --- a/backend/src/main/java/com/launchly/ai/controller/AiController.java +++ b/backend/src/main/java/com/launchly/ai/controller/AiController.java @@ -2,7 +2,11 @@ import com.launchly.ai.dto.request.AiChatRequest; import com.launchly.ai.dto.request.AiSchemaRequest; +import com.launchly.ai.dto.request.CreateAiSessionRequest; +import com.launchly.ai.dto.request.UpdateAiSessionRequest; import com.launchly.ai.dto.response.AiChatResponse; +import com.launchly.ai.dto.response.AiChatSessionDetailResponse; +import com.launchly.ai.dto.response.AiChatSessionResponse; import com.launchly.ai.dto.response.AiSchemaResponse; import com.launchly.ai.dto.response.AiUsageResponse; import com.launchly.ai.service.AiService; @@ -19,14 +23,20 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.concurrent.TimeUnit; @Tag(name = "AI: Assistant & Flow Generator", description = "AI-powered chatbot assistant, conversational helper, and automatic workflow schema generation") @@ -37,6 +47,72 @@ public class AiController { private final AiService aiService; + @Operation(summary = "Get user's AI chat sessions", description = "Retrieve list of all saved AI chat sessions for current user ordered by most recently updated.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List of AI sessions retrieved"), + @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/sessions") + public ResponseEntity> getSessions( + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(aiService.getSessions(userDetails.getId())); + } + + @Operation(summary = "Create a new AI chat session", description = "Initializes an empty AI chat session for the current user.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Session created successfully"), + @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/sessions") + public ResponseEntity createSession( + @Valid @RequestBody(required = false) CreateAiSessionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(aiService.createSession(request, userDetails.getId())); + } + + @Operation(summary = "Get AI chat session details", description = "Retrieve full conversation history for a specific AI chat session.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Session details retrieved"), + @ApiResponse(responseCode = "404", description = "Session not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/sessions/{id}") + public ResponseEntity getSessionDetails( + @PathVariable("id") Long id, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(aiService.getSessionDetails(id, userDetails.getId())); + } + + @Operation(summary = "Update AI chat session title", description = "Renames an existing AI chat session.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Session title updated"), + @ApiResponse(responseCode = "404", description = "Session not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PatchMapping("/sessions/{id}") + public ResponseEntity updateSessionTitle( + @PathVariable("id") Long id, + @Valid @RequestBody UpdateAiSessionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(aiService.updateSessionTitle(id, request, userDetails.getId())); + } + + @Operation(summary = "Delete an AI chat session", description = "Deletes an AI chat session and all its messages.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Session deleted"), + @ApiResponse(responseCode = "404", description = "Session not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/sessions/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ResponseEntity deleteSession( + @PathVariable("id") Long id, + @AuthenticationPrincipal CustomUserDetails userDetails) { + aiService.deleteSession(id, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + @Operation(summary = "Chat with AI assistant", description = "Send a message prompt and conversation history to the AI assistant to receive guidance, recommendations, or troubleshooting advice.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "AI response successfully generated"), @@ -50,7 +126,7 @@ public class AiController { public ResponseEntity chat( @Valid @RequestBody AiChatRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { - return ResponseEntity.ok(aiService.chat(request, userDetails.getId())); + return ResponseEntity.status(HttpStatus.CREATED).body(aiService.chat(request, userDetails.getId())); } @Operation(summary = "Generate chatbot flow schema", description = "Generate interactive chatbot node blocks and connection edges from a natural language text description.") @@ -66,7 +142,7 @@ public ResponseEntity chat( public ResponseEntity generateSchema( @Valid @RequestBody AiSchemaRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { - return ResponseEntity.ok(aiService.generateSchema(request, userDetails.getId())); + return ResponseEntity.status(HttpStatus.CREATED).body(aiService.generateSchema(request, userDetails.getId())); } @Operation(summary = "Get current AI token usage", description = "Retrieve token consumption metrics, monthly plan quota, remaining tokens, and quota reset date.") diff --git a/backend/src/main/java/com/launchly/ai/dto/request/AiChatRequest.java b/backend/src/main/java/com/launchly/ai/dto/request/AiChatRequest.java index 24d4992b..8ef61987 100644 --- a/backend/src/main/java/com/launchly/ai/dto/request/AiChatRequest.java +++ b/backend/src/main/java/com/launchly/ai/dto/request/AiChatRequest.java @@ -7,11 +7,18 @@ @Schema(description = "Request payload for AI assistant conversational chat") public record AiChatRequest( + @Schema(description = "Optional session ID to persist dialogue turn within an ongoing conversation", example = "10") + Long sessionId, + @Schema(description = "User input message prompt", example = "Як підключити Google Таблиці до мого чат-бота?", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "Message is required") String message, - @Schema(description = "Previous dialogue turns for conversational context") + @Schema(description = "Previous dialogue turns for conversational context (used when sessionId is omitted)") List history -) {} +) { + public AiChatRequest(String message, List history) { + this(null, message, history); + } +} diff --git a/backend/src/main/java/com/launchly/ai/dto/request/CreateAiSessionRequest.java b/backend/src/main/java/com/launchly/ai/dto/request/CreateAiSessionRequest.java new file mode 100644 index 00000000..63c821b9 --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/dto/request/CreateAiSessionRequest.java @@ -0,0 +1,11 @@ +package com.launchly.ai.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request payload to initialize a new AI chat session") +public record CreateAiSessionRequest( + @Schema(description = "Optional custom session title", example = "Sales Bot Consultation") + @Size(max = 255, message = "Session title must not exceed 255 characters") + String title +) {} diff --git a/backend/src/main/java/com/launchly/ai/dto/request/UpdateAiSessionRequest.java b/backend/src/main/java/com/launchly/ai/dto/request/UpdateAiSessionRequest.java new file mode 100644 index 00000000..a2e25bde --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/dto/request/UpdateAiSessionRequest.java @@ -0,0 +1,13 @@ +package com.launchly.ai.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request payload to update an AI chat session") +public record UpdateAiSessionRequest( + @Schema(description = "New title for the session", example = "CRM Automation Ideas", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "Session title cannot be blank") + @Size(max = 255, message = "Session title must not exceed 255 characters") + String title +) {} diff --git a/backend/src/main/java/com/launchly/ai/dto/response/AiChatMessageResponse.java b/backend/src/main/java/com/launchly/ai/dto/response/AiChatMessageResponse.java new file mode 100644 index 00000000..e8c4bfd1 --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/dto/response/AiChatMessageResponse.java @@ -0,0 +1,22 @@ +package com.launchly.ai.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "Single chat message in an AI conversation") +public record AiChatMessageResponse( + @Schema(description = "Message ID", example = "1") + Long id, + + @Schema(description = "Message sender role: user, assistant, system", example = "user") + String role, + + @Schema(description = "Message markdown content text") + String content, + + @Schema(description = "Estimated token count consumed by message", example = "42") + Integer tokensUsed, + + @Schema(description = "Creation timestamp") + LocalDateTime createdAt +) {} diff --git a/backend/src/main/java/com/launchly/ai/dto/response/AiChatResponse.java b/backend/src/main/java/com/launchly/ai/dto/response/AiChatResponse.java index 027cdf6d..88e227d9 100644 --- a/backend/src/main/java/com/launchly/ai/dto/response/AiChatResponse.java +++ b/backend/src/main/java/com/launchly/ai/dto/response/AiChatResponse.java @@ -1,13 +1,27 @@ package com.launchly.ai.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; @Schema(description = "Response returned from AI assistant chat") public record AiChatResponse( + @Schema(description = "Session ID if persisted", example = "10") + Long sessionId, + + @Schema(description = "Updated or assigned session title", example = "Google Sheets setup") + String sessionTitle, + @Schema(description = "AI generated markdown response text", example = "Щоб підключити Google Таблиці, перейдіть у розділ Інтеграції та авторизуйте ваш обліковий запис Google...") String reply, @Schema(description = "Updated AI usage and remaining quota metrics") - AiUsageResponse usage -) {} + AiUsageResponse usage, + + @Schema(description = "Full list of messages in session if session was loaded/updated") + List messages +) { + public AiChatResponse(String reply, AiUsageResponse usage) { + this(null, null, reply, usage, null); + } +} diff --git a/backend/src/main/java/com/launchly/ai/dto/response/AiChatSessionDetailResponse.java b/backend/src/main/java/com/launchly/ai/dto/response/AiChatSessionDetailResponse.java new file mode 100644 index 00000000..b329d73c --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/dto/response/AiChatSessionDetailResponse.java @@ -0,0 +1,23 @@ +package com.launchly.ai.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "Detailed AI chat session including full message history") +public record AiChatSessionDetailResponse( + @Schema(description = "Session ID", example = "10") + Long id, + + @Schema(description = "Session display title", example = "Telegram bot setup") + String title, + + @Schema(description = "Creation timestamp") + LocalDateTime createdAt, + + @Schema(description = "Last update timestamp") + LocalDateTime updatedAt, + + @Schema(description = "Chronological list of dialogue messages") + List messages +) {} diff --git a/backend/src/main/java/com/launchly/ai/dto/response/AiChatSessionResponse.java b/backend/src/main/java/com/launchly/ai/dto/response/AiChatSessionResponse.java new file mode 100644 index 00000000..b2fbf5bd --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/dto/response/AiChatSessionResponse.java @@ -0,0 +1,22 @@ +package com.launchly.ai.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "Summary of an AI chat session") +public record AiChatSessionResponse( + @Schema(description = "Session ID", example = "10") + Long id, + + @Schema(description = "Session display title", example = "Telegram bot setup") + String title, + + @Schema(description = "Creation timestamp") + LocalDateTime createdAt, + + @Schema(description = "Last update timestamp") + LocalDateTime updatedAt, + + @Schema(description = "Last message preview snippet if available") + String lastMessage +) {} diff --git a/backend/src/main/java/com/launchly/ai/entity/AiChatMessage.java b/backend/src/main/java/com/launchly/ai/entity/AiChatMessage.java new file mode 100644 index 00000000..11adcb60 --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/entity/AiChatMessage.java @@ -0,0 +1,48 @@ +package com.launchly.ai.entity; + +import com.launchly.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Table( + name = "ai_chat_messages", + indexes = { + @Index(name = "idx_ai_chat_messages_session_created", columnList = "session_id, created_at ASC") + } +) +@Getter +@Setter +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@ToString(exclude = {"session"}) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiChatMessage extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + private AiChatSession session; + + @Column(name = "role", nullable = false, length = 32) + private String role; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "tokens_used") + @Builder.Default + private Integer tokensUsed = 0; +} diff --git a/backend/src/main/java/com/launchly/ai/entity/AiChatSession.java b/backend/src/main/java/com/launchly/ai/entity/AiChatSession.java new file mode 100644 index 00000000..0161a2ed --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/entity/AiChatSession.java @@ -0,0 +1,53 @@ +package com.launchly.ai.entity; + +import com.launchly.auth.entity.User; +import com.launchly.common.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table( + name = "ai_chat_sessions", + indexes = { + @Index(name = "idx_ai_chat_sessions_user_updated", columnList = "user_id, updated_at DESC") + } +) +@Getter +@Setter +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@ToString(exclude = {"user", "messages"}) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiChatSession extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "title", length = 255) + private String title; + + @Builder.Default + @OneToMany(mappedBy = "session", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @OrderBy("createdAt ASC") + private List messages = new ArrayList<>(); +} diff --git a/backend/src/main/java/com/launchly/ai/entity/AiUsage.java b/backend/src/main/java/com/launchly/ai/entity/AiUsage.java index e8f64532..c2af5cbc 100644 --- a/backend/src/main/java/com/launchly/ai/entity/AiUsage.java +++ b/backend/src/main/java/com/launchly/ai/entity/AiUsage.java @@ -12,9 +12,11 @@ import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import java.time.LocalDate; @Entity @@ -24,8 +26,10 @@ @Index(name = "idx_ai_usage_user_date", columnList = "user_id, usage_date") } ) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"user"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/ai/repository/AiChatMessageRepository.java b/backend/src/main/java/com/launchly/ai/repository/AiChatMessageRepository.java new file mode 100644 index 00000000..bdbe92c9 --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/repository/AiChatMessageRepository.java @@ -0,0 +1,13 @@ +package com.launchly.ai.repository; + +import com.launchly.ai.entity.AiChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AiChatMessageRepository extends JpaRepository { + + List findAllBySessionIdOrderByCreatedAtAsc(Long sessionId); +} diff --git a/backend/src/main/java/com/launchly/ai/repository/AiChatSessionRepository.java b/backend/src/main/java/com/launchly/ai/repository/AiChatSessionRepository.java new file mode 100644 index 00000000..7cc0b1a6 --- /dev/null +++ b/backend/src/main/java/com/launchly/ai/repository/AiChatSessionRepository.java @@ -0,0 +1,21 @@ +package com.launchly.ai.repository; + +import com.launchly.ai.entity.AiChatSession; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface AiChatSessionRepository extends JpaRepository { + + List findAllByUserIdOrderByUpdatedAtDesc(Long userId); + + Optional findByIdAndUserId(Long id, Long userId); + + @Query("SELECT s FROM AiChatSession s LEFT JOIN FETCH s.messages WHERE s.id = :id AND s.user.id = :userId") + Optional findByIdAndUserIdWithMessages(@Param("id") Long id, @Param("userId") Long userId); +} diff --git a/backend/src/main/java/com/launchly/ai/service/AiService.java b/backend/src/main/java/com/launchly/ai/service/AiService.java index a45bf746..fd080e4e 100644 --- a/backend/src/main/java/com/launchly/ai/service/AiService.java +++ b/backend/src/main/java/com/launchly/ai/service/AiService.java @@ -2,12 +2,27 @@ import com.launchly.ai.dto.request.AiChatRequest; import com.launchly.ai.dto.request.AiSchemaRequest; +import com.launchly.ai.dto.request.CreateAiSessionRequest; +import com.launchly.ai.dto.request.UpdateAiSessionRequest; import com.launchly.ai.dto.response.AiChatResponse; +import com.launchly.ai.dto.response.AiChatSessionDetailResponse; +import com.launchly.ai.dto.response.AiChatSessionResponse; import com.launchly.ai.dto.response.AiSchemaResponse; import com.launchly.ai.dto.response.AiUsageResponse; +import java.util.List; public interface AiService { + List getSessions(Long userId); + + AiChatSessionDetailResponse getSessionDetails(Long sessionId, Long userId); + + AiChatSessionResponse createSession(CreateAiSessionRequest request, Long userId); + + AiChatSessionResponse updateSessionTitle(Long sessionId, UpdateAiSessionRequest request, Long userId); + + void deleteSession(Long sessionId, Long userId); + AiChatResponse chat(AiChatRequest request, Long userId); AiSchemaResponse generateSchema(AiSchemaRequest request, Long userId); diff --git a/backend/src/main/java/com/launchly/ai/service/impl/AiServiceImpl.java b/backend/src/main/java/com/launchly/ai/service/impl/AiServiceImpl.java index cb518143..163dbfd9 100644 --- a/backend/src/main/java/com/launchly/ai/service/impl/AiServiceImpl.java +++ b/backend/src/main/java/com/launchly/ai/service/impl/AiServiceImpl.java @@ -5,13 +5,24 @@ import com.launchly.ai.dto.AiMessage; import com.launchly.ai.dto.request.AiChatRequest; import com.launchly.ai.dto.request.AiSchemaRequest; +import com.launchly.ai.dto.request.CreateAiSessionRequest; +import com.launchly.ai.dto.request.UpdateAiSessionRequest; +import com.launchly.ai.dto.response.AiChatMessageResponse; import com.launchly.ai.dto.response.AiChatResponse; +import com.launchly.ai.dto.response.AiChatSessionDetailResponse; +import com.launchly.ai.dto.response.AiChatSessionResponse; import com.launchly.ai.dto.response.AiSchemaResponse; import com.launchly.ai.dto.response.AiUsageResponse; +import com.launchly.ai.entity.AiChatMessage; +import com.launchly.ai.entity.AiChatSession; +import com.launchly.ai.repository.AiChatMessageRepository; +import com.launchly.ai.repository.AiChatSessionRepository; import com.launchly.ai.service.AiProviderRouter; import com.launchly.ai.service.AiService; import com.launchly.ai.service.AiUsageService; import com.launchly.ai.util.AiSchemaUtils; +import com.launchly.auth.entity.User; +import com.launchly.auth.repository.UserRepository; import com.launchly.billing.entity.Plan; import com.launchly.billing.service.PlanLimitService; import jakarta.annotation.PostConstruct; @@ -20,12 +31,14 @@ import io.github.resilience4j.retry.annotation.Retry; import com.launchly.common.exception.AppException; import org.springframework.http.HttpStatus; +import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; -import java.io.IOException; +import org.springframework.transaction.support.TransactionTemplate; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -35,10 +48,19 @@ @RequiredArgsConstructor public class AiServiceImpl implements AiService { + private static final int AVG_CHARS_PER_TOKEN = 4; + private static final int MIN_USER_TOKENS = 10; + private static final int MIN_ESTIMATED_TOKENS = 1000; + private static final int AVG_CHARS_PER_SCHEMA_TOKEN = 3; + private final AiProviderRouter aiProviderRouter; private final AiUsageService aiUsageService; private final PlanLimitService planLimitService; private final ObjectMapper objectMapper; + private final AiChatSessionRepository aiChatSessionRepository; + private final AiChatMessageRepository aiChatMessageRepository; + private final UserRepository userRepository; + private final TransactionTemplate transactionTemplate; @Value("${ai.prompt.chat-path:classpath:prompts/chat-system.txt}") private Resource chatPromptResource; @@ -65,6 +87,78 @@ void init() { } } + @Override + @Transactional(readOnly = true) + public List getSessions(Long userId) { + List sessions = aiChatSessionRepository.findAllByUserIdOrderByUpdatedAtDesc(userId); + return sessions.stream() + .map(s -> { + String lastMessage = null; + if (s.getMessages() != null && !s.getMessages().isEmpty()) { + lastMessage = s.getMessages().get(s.getMessages().size() - 1).getContent(); + } + return new AiChatSessionResponse(s.getId(), s.getTitle(), s.getCreatedAt(), s.getUpdatedAt(), lastMessage); + }) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public AiChatSessionDetailResponse getSessionDetails(Long sessionId, Long userId) { + AiChatSession session = aiChatSessionRepository.findByIdAndUserIdWithMessages(sessionId, userId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "ai.session.not_found")); + + List messages = session.getMessages().stream() + .map(m -> new AiChatMessageResponse(m.getId(), m.getRole(), m.getContent(), m.getTokensUsed(), m.getCreatedAt())) + .toList(); + + return new AiChatSessionDetailResponse(session.getId(), session.getTitle(), session.getCreatedAt(), session.getUpdatedAt(), messages); + } + + @Override + @Transactional + public AiChatSessionResponse createSession(CreateAiSessionRequest request, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "user.error.not_found")); + + String title = request != null && request.title() != null && !request.title().isBlank() + ? request.title().trim() + : null; + + AiChatSession session = AiChatSession.builder() + .user(user) + .title(title) + .build(); + + session = aiChatSessionRepository.save(session); + return new AiChatSessionResponse(session.getId(), session.getTitle(), session.getCreatedAt(), session.getUpdatedAt(), null); + } + + @Override + @Transactional + public AiChatSessionResponse updateSessionTitle(Long sessionId, UpdateAiSessionRequest request, Long userId) { + AiChatSession session = aiChatSessionRepository.findByIdAndUserId(sessionId, userId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "ai.session.not_found")); + + session.setTitle(request.title().trim()); + session = aiChatSessionRepository.save(session); + + String lastMessage = null; + if (session.getMessages() != null && !session.getMessages().isEmpty()) { + lastMessage = session.getMessages().get(session.getMessages().size() - 1).getContent(); + } + + return new AiChatSessionResponse(session.getId(), session.getTitle(), session.getCreatedAt(), session.getUpdatedAt(), lastMessage); + } + + @Override + @Transactional + public void deleteSession(Long sessionId, Long userId) { + AiChatSession session = aiChatSessionRepository.findByIdAndUserId(sessionId, userId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "ai.session.not_found")); + aiChatSessionRepository.delete(session); + } + @Override @CircuitBreaker(name = "aiProvider", fallbackMethod = "chatFallback") @Retry(name = "aiProvider") @@ -72,19 +166,72 @@ public AiChatResponse chat(AiChatRequest request, Long userId) { Plan plan = planLimitService.getActivePlan(userId); aiUsageService.checkTokenLimit(userId, plan); + AiChatSession session = null; List messages = new ArrayList<>(); messages.add(new AiMessage("system", chatSystemPrompt)); - if (request.history() != null) { + + if (request.sessionId() != null) { + session = aiChatSessionRepository.findByIdAndUserIdWithMessages(request.sessionId(), userId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "ai.session.not_found")); + + for (AiChatMessage existingMsg : session.getMessages()) { + messages.add(new AiMessage(existingMsg.getRole(), existingMsg.getContent())); + } + } else if (request.history() != null) { messages.addAll(request.history()); } + messages.add(new AiMessage("user", request.message())); String reply = aiProviderRouter.chat(messages, null); - int estimatedTokens = Math.max(500, (chatSystemPrompt.length() + request.message().length() + (reply != null ? reply.length() : 0)) / 3); + int estimatedTokens = Math.max(500, (chatSystemPrompt.length() + request.message().length() + (reply != null ? reply.length() : 0)) / AVG_CHARS_PER_SCHEMA_TOKEN); aiUsageService.recordTokenUsage(userId, plan, estimatedTokens); AiUsageResponse usage = aiUsageService.getUsage(userId, plan); + + if (session != null) { + final String finalReply = reply; + final AiChatSession targetSession = session; + List mappedMessages = transactionTemplate.execute(status -> { + int userTokens = Math.max(MIN_USER_TOKENS, request.message().length() / AVG_CHARS_PER_TOKEN); + int replyTokens = Math.max(MIN_USER_TOKENS, (finalReply != null ? finalReply.length() : 0) / AVG_CHARS_PER_TOKEN); + + AiChatMessage userChatMessage = AiChatMessage.builder() + .session(targetSession) + .role("user") + .content(request.message()) + .tokensUsed(userTokens) + .build(); + targetSession.getMessages().add(userChatMessage); + + AiChatMessage assistantChatMessage = AiChatMessage.builder() + .session(targetSession) + .role("assistant") + .content(finalReply) + .tokensUsed(replyTokens) + .build(); + targetSession.getMessages().add(assistantChatMessage); + + if (targetSession.getTitle() == null || targetSession.getTitle().isBlank()) { + String autoTitle = request.message().trim(); + if (autoTitle.length() > 40) { + autoTitle = autoTitle.substring(0, 40) + "..."; + } + targetSession.setTitle(autoTitle); + } + + targetSession.setUpdatedAt(LocalDateTime.now()); + AiChatSession saved = aiChatSessionRepository.save(targetSession); + + return saved.getMessages().stream() + .map(m -> new AiChatMessageResponse(m.getId(), m.getRole(), m.getContent(), m.getTokensUsed(), m.getCreatedAt())) + .toList(); + }); + + return new AiChatResponse(targetSession.getId(), targetSession.getTitle(), reply, usage, mappedMessages); + } + return new AiChatResponse(reply, usage); } @@ -126,7 +273,7 @@ public AiSchemaResponse generateSchema(AiSchemaRequest request, Long userId) { nodesNode = AiSchemaUtils.normalizeUsernameActions(nodesNode); } - int estimatedTokens = Math.max(1000, (schemaSystemPrompt.length() + request.description().length() + rawResponse.length()) / 3); + int estimatedTokens = Math.max(MIN_ESTIMATED_TOKENS, (schemaSystemPrompt.length() + request.description().length() + rawResponse.length()) / AVG_CHARS_PER_SCHEMA_TOKEN); aiUsageService.recordTokenUsage(userId, plan, estimatedTokens); AiUsageResponse usage = aiUsageService.getUsage(userId, plan); diff --git a/backend/src/main/java/com/launchly/analytics/entity/AnalyticsEvent.java b/backend/src/main/java/com/launchly/analytics/entity/AnalyticsEvent.java index 8f81ae65..39587aaa 100644 --- a/backend/src/main/java/com/launchly/analytics/entity/AnalyticsEvent.java +++ b/backend/src/main/java/com/launchly/analytics/entity/AnalyticsEvent.java @@ -14,9 +14,11 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; @Entity @Table(name = "analytics_events", indexes = { @@ -25,8 +27,10 @@ @Index(name = "idx_analytics_events_type_created", columnList = "event_type, created_at"), @Index(name = "idx_analytics_bot_type_created", columnList = "bot_id, event_type, created_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot", "botUser"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/analytics/service/impl/AnalyticsServiceImpl.java b/backend/src/main/java/com/launchly/analytics/service/impl/AnalyticsServiceImpl.java index b89b55e3..64633b32 100644 --- a/backend/src/main/java/com/launchly/analytics/service/impl/AnalyticsServiceImpl.java +++ b/backend/src/main/java/com/launchly/analytics/service/impl/AnalyticsServiceImpl.java @@ -12,6 +12,7 @@ import com.launchly.bot.repository.BotRepository; import com.launchly.bot.repository.BotUserRepository; import com.launchly.bot.repository.FlowSchemaRepository; +import com.launchly.bot.engine.router.FlowNodeRouter; import com.launchly.common.exception.AppException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,9 +22,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import com.launchly.bot.repository.BotMemberRepository; -import com.launchly.bot.entity.BotMember; -import com.launchly.auth.entity.User; import com.launchly.broadcast.repository.BotUserTagRepository; @Slf4j @@ -35,9 +33,9 @@ public class AnalyticsServiceImpl implements AnalyticsService { private final BotRepository botRepository; private final BotUserRepository botUserRepository; private final FlowSchemaRepository flowSchemaRepository; - private final BotMemberRepository botMemberRepository; private final BotUserTagRepository botUserTagRepository; private final ObjectMapper objectMapper; + private final FlowNodeRouter flowNodeRouter; @Override @Transactional @@ -90,17 +88,7 @@ public DashboardStatsResponse getDashboardStats(Long botId, int days, Long userI List rawHeatmap = new ArrayList<>(); if (botId == 0) { - List userBots = new ArrayList<>(botRepository.findAllByUserId(userId)); - List memberships = botMemberRepository.findByUserId(userId); - for (BotMember bm : memberships) { - User owner = bm.getBot().getUser(); - List ownerBots = botRepository.findAllByUserId(owner.getId()); - for (Bot b : ownerBots) { - if (userBots.stream().noneMatch(existing -> existing.getId().equals(b.getId()))) { - userBots.add(b); - } - } - } + List userBots = botRepository.findAllAccessibleByUserId(userId); if (userBots.isEmpty()) { return new DashboardStatsResponse(0L, 0L, 0L, 0L, new ArrayList<>(), new ArrayList<>(), 0L, 0, 0L, 0.0, new ArrayList<>(), new ArrayList<>(), 0.0, 0.0, 0.0, 0.0); @@ -110,15 +98,12 @@ public DashboardStatsResponse getDashboardStats(Long botId, int days, Long userI totalSubscribers = botUserRepository.countDistinctTelegramIdByBotIdIn(botIds); activeUsers24h = analyticsEventRepository.countActiveUsersByBotIdsAndCreatedAtAfter(botIds, start24h); clicksCount30d = analyticsEventRepository.countClicksByBotIdsAndCreatedAtAfter(botIds, startClicks30d); - activeAutomations = userBots.stream().filter(Bot::isActive).count(); + activeAutomations = botRepository.countAccessibleByUserIdAndActiveTrue(userId); long totalSubscribersLastWeek = botUserRepository.countDistinctTelegramIdByBotIdInAndCreatedAtBefore(botIds, lastWeekDate); long activeUsersYesterday = analyticsEventRepository.countActiveUsersByBotIdsAndCreatedAtBetween(botIds, startYesterday, start24h); long clicksCountLastMonth = analyticsEventRepository.countClicksByBotIdsAndCreatedAtBetween(botIds, startClicks60d, startClicks30d); - long activeAutomationsLastWeek = userBots.stream() - .filter(Bot::isActive) - .filter(b -> b.getCreatedAt() != null && b.getCreatedAt().isBefore(lastWeekDate)) - .count(); + long activeAutomationsLastWeek = botRepository.countAccessibleByUserIdAndActiveTrueAndCreatedAtBefore(userId, lastWeekDate); subscribersGrowth = AnalyticsUtils.calculateGrowth(totalSubscribersLastWeek, totalSubscribers); activeUsersGrowth = AnalyticsUtils.calculateGrowth(activeUsersYesterday, activeUsers24h); @@ -130,7 +115,7 @@ public DashboardStatsResponse getDashboardStats(Long botId, int days, Long userI rawHeatmap = analyticsEventRepository.getActivityHeatmapForBots(botIds, startActivityDate); } else { Bot bot = botRepository.findByIdAndUserId(botId, userId) - .orElseThrow(() -> new AppException(HttpStatus.FORBIDDEN, "Access denied to bot analytics")); + .orElseThrow(() -> new AppException(HttpStatus.FORBIDDEN, "analytics.error.access_denied")); botIds.add(botId); @@ -138,28 +123,12 @@ public DashboardStatsResponse getDashboardStats(Long botId, int days, Long userI activeUsers24h = analyticsEventRepository.countActiveUsersByBotIdAndCreatedAtAfter(botId, start24h); clicksCount30d = analyticsEventRepository.countClicksByBotIdAndCreatedAtAfter(botId, startClicks30d); - List userBots = new ArrayList<>(botRepository.findAllByUserId(userId)); - List memberships = botMemberRepository.findByUserId(userId); - for (BotMember bm : memberships) { - User owner = bm.getBot().getUser(); - List ownerBots = botRepository.findAllByUserId(owner.getId()); - for (Bot b : ownerBots) { - if (userBots.stream().noneMatch(existing -> existing.getId().equals(b.getId()))) { - userBots.add(b); - } - } - } - activeAutomations = userBots.stream() - .filter(Bot::isActive) - .count(); + activeAutomations = botRepository.countAccessibleByUserIdAndActiveTrue(userId); long totalSubscribersLastWeek = botUserRepository.countByBotIdAndCreatedAtBefore(botId, lastWeekDate); long activeUsersYesterday = analyticsEventRepository.countActiveUsersByBotIdAndCreatedAtBetween(botId, startYesterday, start24h); long clicksCountLastMonth = analyticsEventRepository.countClicksByBotIdAndCreatedAtBetween(botId, startClicks60d, startClicks30d); - long activeAutomationsLastWeek = userBots.stream() - .filter(Bot::isActive) - .filter(b -> b.getCreatedAt() != null && b.getCreatedAt().isBefore(lastWeekDate)) - .count(); + long activeAutomationsLastWeek = botRepository.countAccessibleByUserIdAndActiveTrueAndCreatedAtBefore(userId, lastWeekDate); subscribersGrowth = AnalyticsUtils.calculateGrowth(totalSubscribersLastWeek, totalSubscribers); activeUsersGrowth = AnalyticsUtils.calculateGrowth(activeUsersYesterday, activeUsers24h); @@ -182,8 +151,12 @@ public DashboardStatsResponse getDashboardStats(Long botId, int days, Long userI List topButtons = new ArrayList<>(); for (Object[] row : rawButtons) { String btnName = row[0] != null ? row[0].toString() : "Unknown"; - if (btnName.startsWith("btn_")) { - btnName = AnalyticsUtils.resolveButtonLabel(flowSchemaRepository, botIds, btnName); + for (Long bId : botIds) { + String resolved = flowNodeRouter.resolveButtonLabel(bId, btnName); + if (resolved != null && !resolved.equals(btnName)) { + btnName = resolved; + break; + } } long clicks = row[1] != null ? ((Number) row[1]).longValue() : 0L; topButtons.add(new DashboardStatsResponse.ButtonStatsEntry(btnName, clicks)); diff --git a/backend/src/main/java/com/launchly/analytics/util/AnalyticsUtils.java b/backend/src/main/java/com/launchly/analytics/util/AnalyticsUtils.java index aad744ed..f623205e 100644 --- a/backend/src/main/java/com/launchly/analytics/util/AnalyticsUtils.java +++ b/backend/src/main/java/com/launchly/analytics/util/AnalyticsUtils.java @@ -1,17 +1,7 @@ package com.launchly.analytics.util; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.launchly.bot.engine.model.FlowNode; -import com.launchly.bot.entity.FlowSchema; -import com.launchly.bot.repository.FlowSchemaRepository; -import java.util.List; -import java.util.Map; - public final class AnalyticsUtils { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private AnalyticsUtils() { } @@ -23,62 +13,4 @@ public static double calculateGrowth(long prev, long curr) { double growth = (diff / prev) * 100.0; return Math.round(growth * 10.0) / 10.0; } - - @SuppressWarnings("unchecked") - public static String resolveButtonLabel(FlowSchemaRepository flowSchemaRepository, List botIds, String callbackData) { - if (callbackData == null) { - return "Unknown Button"; - } - if (flowSchemaRepository == null || botIds == null) { - return callbackData; - } - for (Long bId : botIds) { - try { - FlowSchema schema = flowSchemaRepository.findByBotId(bId).orElse(null); - if (schema != null && schema.getNodes() != null) { - List nodes = OBJECT_MAPPER.readValue( - schema.getNodes(), - new TypeReference>() {} - ); - for (FlowNode node : nodes) { - Map data = node.data(); - if (data == null) continue; - - List topLevelButtons = (List) data.get("buttons"); - if (topLevelButtons != null) { - for (Object btnObj : topLevelButtons) { - if (btnObj instanceof Map) { - Map btn = (Map) btnObj; - if (callbackData.equals(btn.get("value"))) { - Object label = btn.get("label"); - if (label != null) return label.toString(); - } - } - } - } - - List> blocks = (List>) data.get("blocks"); - if (blocks != null) { - for (Map block : blocks) { - List blockButtons = (List) block.get("buttons"); - if (blockButtons != null) { - for (Object btnObj : blockButtons) { - if (btnObj instanceof Map) { - Map btn = (Map) btnObj; - if (callbackData.equals(btn.get("value"))) { - Object label = btn.get("label"); - if (label != null) return label.toString(); - } - } - } - } - } - } - } - } - } catch (Exception ignored) { - } - } - return callbackData; - } } diff --git a/backend/src/main/java/com/launchly/auth/controller/AuthController.java b/backend/src/main/java/com/launchly/auth/controller/AuthController.java index 561654cc..b01ed671 100644 --- a/backend/src/main/java/com/launchly/auth/controller/AuthController.java +++ b/backend/src/main/java/com/launchly/auth/controller/AuthController.java @@ -110,8 +110,7 @@ public ResponseEntity updateProfile(@Valid @RequestBody UpdateProf }) @DeleteMapping("/account") public ResponseEntity deleteAccount(Authentication authentication) { - UserResponse currentUser = authService.getCurrentUser(authentication.getName()); - authService.deleteUserAccount(currentUser.id()); + authService.deleteAccountByEmail(authentication.getName()); return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/com/launchly/auth/dto/request/LoginRequest.java b/backend/src/main/java/com/launchly/auth/dto/request/LoginRequest.java index 103c3eba..966b5daa 100644 --- a/backend/src/main/java/com/launchly/auth/dto/request/LoginRequest.java +++ b/backend/src/main/java/com/launchly/auth/dto/request/LoginRequest.java @@ -14,6 +14,13 @@ public record LoginRequest( @Schema(description = "Account password", example = "SecretPass123!", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "Password is required") - String password -) {} + String password, + + @Schema(description = "Cloudflare Turnstile captcha token") + String turnstileToken +) { + public LoginRequest(String email, String password) { + this(email, password, null); + } +} diff --git a/backend/src/main/java/com/launchly/auth/dto/request/RegisterRequest.java b/backend/src/main/java/com/launchly/auth/dto/request/RegisterRequest.java index 62a2b416..507e9d05 100644 --- a/backend/src/main/java/com/launchly/auth/dto/request/RegisterRequest.java +++ b/backend/src/main/java/com/launchly/auth/dto/request/RegisterRequest.java @@ -20,6 +20,13 @@ public record RegisterRequest( @Schema(description = "User display name", example = "John Doe", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "Name is required") - String name -) {} + String name, + + @Schema(description = "Cloudflare Turnstile captcha token") + String turnstileToken +) { + public RegisterRequest(String email, String password, String name) { + this(email, password, name, null); + } +} diff --git a/backend/src/main/java/com/launchly/auth/entity/RefreshToken.java b/backend/src/main/java/com/launchly/auth/entity/RefreshToken.java index e7b3ef71..cd025147 100644 --- a/backend/src/main/java/com/launchly/auth/entity/RefreshToken.java +++ b/backend/src/main/java/com/launchly/auth/entity/RefreshToken.java @@ -10,9 +10,11 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import java.time.Instant; @Entity @@ -20,8 +22,10 @@ @Index(name = "idx_refresh_tokens_token", columnList = "token"), @Index(name = "idx_refresh_tokens_user_id", columnList = "user_id") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"user"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/auth/entity/TelegramAuthSession.java b/backend/src/main/java/com/launchly/auth/entity/TelegramAuthSession.java index 61764cf4..cbbfcba5 100644 --- a/backend/src/main/java/com/launchly/auth/entity/TelegramAuthSession.java +++ b/backend/src/main/java/com/launchly/auth/entity/TelegramAuthSession.java @@ -13,13 +13,19 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import java.time.LocalDateTime; @Entity @Table(name = "telegram_auth_sessions") -@Data +@Getter +@Setter +@ToString(exclude = {"user"}) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor @@ -27,6 +33,7 @@ public class TelegramAuthSession { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include private Long id; @Column(nullable = false, unique = true) diff --git a/backend/src/main/java/com/launchly/auth/entity/User.java b/backend/src/main/java/com/launchly/auth/entity/User.java index 23d55619..6faed29d 100644 --- a/backend/src/main/java/com/launchly/auth/entity/User.java +++ b/backend/src/main/java/com/launchly/auth/entity/User.java @@ -8,7 +8,9 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; @@ -17,8 +19,10 @@ @Entity @Table(name = "users") -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/auth/repository/UserRepository.java b/backend/src/main/java/com/launchly/auth/repository/UserRepository.java index e6abfb97..37d3fda1 100644 --- a/backend/src/main/java/com/launchly/auth/repository/UserRepository.java +++ b/backend/src/main/java/com/launchly/auth/repository/UserRepository.java @@ -1,12 +1,15 @@ package com.launchly.auth.repository; +import com.launchly.auth.entity.Role; import com.launchly.auth.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { @@ -22,6 +25,18 @@ public interface UserRepository extends JpaRepository, JpaSpecificat long countByActiveTrue(); + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + long countByRoleInAndCreatedAtBetween(Collection roles, LocalDateTime start, LocalDateTime end); + + long countByRoleAndCreatedAtBetween(Role role, LocalDateTime start, LocalDateTime end); + + long countByRoleIn(Collection roles); + + long countByActiveTrueAndUpdatedAtAfter(LocalDateTime date); + + List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + @Query("SELECT u FROM User u WHERE u.statsNotificationsEnabled = true AND " + "(UPPER(u.statsDayOfWeek) = 'DAILY' OR UPPER(u.statsDayOfWeek) = :dayOfWeek) AND " + "u.statsHour = :hour") diff --git a/backend/src/main/java/com/launchly/common/security/OAuth2SuccessHandler.java b/backend/src/main/java/com/launchly/auth/security/OAuth2SuccessHandler.java similarity index 95% rename from backend/src/main/java/com/launchly/common/security/OAuth2SuccessHandler.java rename to backend/src/main/java/com/launchly/auth/security/OAuth2SuccessHandler.java index 00da694b..1389b8f0 100644 --- a/backend/src/main/java/com/launchly/common/security/OAuth2SuccessHandler.java +++ b/backend/src/main/java/com/launchly/auth/security/OAuth2SuccessHandler.java @@ -1,4 +1,4 @@ -package com.launchly.common.security; +package com.launchly.auth.security; import com.launchly.auth.entity.Provider; import com.launchly.auth.entity.Role; @@ -14,6 +14,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -65,7 +66,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo } else { if (!user.isActive()) { String reason = user.getBlockReason() != null ? user.getBlockReason() : "admin.reason_rules"; - String targetUrl = org.springframework.web.util.UriComponentsBuilder.fromUriString(frontendUrl + "/blocked") + String targetUrl = UriComponentsBuilder.fromUriString(frontendUrl + "/blocked") .queryParam("code", URLEncoder.encode(reason, StandardCharsets.UTF_8)) .build().toUriString(); response.sendRedirect(targetUrl); diff --git a/backend/src/main/java/com/launchly/auth/service/AuthService.java b/backend/src/main/java/com/launchly/auth/service/AuthService.java index 11c1c5d9..ff3f0d70 100644 --- a/backend/src/main/java/com/launchly/auth/service/AuthService.java +++ b/backend/src/main/java/com/launchly/auth/service/AuthService.java @@ -2,6 +2,7 @@ import com.launchly.auth.dto.request.LoginRequest; import com.launchly.auth.dto.request.RegisterRequest; +import com.launchly.auth.dto.request.UpdateProfileRequest; import com.launchly.auth.dto.response.AuthResponse; import com.launchly.auth.dto.response.TelegramSessionResponse; import com.launchly.auth.dto.response.TelegramStatusResponse; @@ -19,7 +20,7 @@ public interface AuthService { UserResponse getCurrentUser(String email); - UserResponse updateProfile(String currentEmail, com.launchly.auth.dto.request.UpdateProfileRequest request); + UserResponse updateProfile(String currentEmail, UpdateProfileRequest request); TelegramSessionResponse createTelegramSession(String currentEmail, boolean isSubscription); @@ -30,4 +31,6 @@ public interface AuthService { boolean handleTelegramAuth(String token, Long telegramUserId, String telegramUsername, String telegramName, String telegramPhotoUrl); void deleteUserAccount(Long userId); + + void deleteAccountByEmail(String email); } diff --git a/backend/src/main/java/com/launchly/auth/service/UserQueryService.java b/backend/src/main/java/com/launchly/auth/service/UserQueryService.java index e09b6af5..d5e319df 100644 --- a/backend/src/main/java/com/launchly/auth/service/UserQueryService.java +++ b/backend/src/main/java/com/launchly/auth/service/UserQueryService.java @@ -1,6 +1,9 @@ package com.launchly.auth.service; +import com.launchly.auth.entity.Role; import com.launchly.auth.entity.User; +import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -26,6 +29,18 @@ public interface UserQueryService { long countActiveUsers(); + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + long countByRoleInAndCreatedAtBetween(Collection roles, LocalDateTime start, LocalDateTime end); + + long countByRoleAndCreatedAtBetween(Role role, LocalDateTime start, LocalDateTime end); + + long countByRoleIn(Collection roles); + + long countByActiveTrueAndUpdatedAtAfter(LocalDateTime date); + + List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + List findAllUsers(); List findUsersForStatsNotification(String dayOfWeek, int hour); diff --git a/backend/src/main/java/com/launchly/auth/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/launchly/auth/service/impl/AuthServiceImpl.java index c28264c8..715f09cc 100644 --- a/backend/src/main/java/com/launchly/auth/service/impl/AuthServiceImpl.java +++ b/backend/src/main/java/com/launchly/auth/service/impl/AuthServiceImpl.java @@ -14,27 +14,27 @@ import com.launchly.auth.service.AuthService; import com.launchly.auth.service.TokenService; import com.launchly.billing.service.BillingService; -import com.launchly.bot.entity.Bot; -import com.launchly.bot.entity.BotMember; +import com.launchly.bot.service.BotService; import com.launchly.common.exception.AppException; import com.launchly.auth.entity.AuthSessionStatus; import com.launchly.auth.entity.TelegramAuthSession; import com.launchly.auth.repository.TelegramAuthSessionRepository; +import com.launchly.admin.service.UserAuditService; +import com.launchly.common.security.turnstile.TurnstileService; +import com.launchly.common.utils.MessageUtils; import com.launchly.auth.dto.response.TelegramSessionResponse; import com.launchly.auth.dto.response.TelegramStatusResponse; import org.springframework.beans.factory.annotation.Value; -import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import java.util.UUID; @Service -@RequiredArgsConstructor public class AuthServiceImpl implements AuthService { private final UserRepository userRepository; @@ -43,11 +43,33 @@ public class AuthServiceImpl implements AuthService { private final PasswordEncoder passwordEncoder; private final BillingService billingService; private final TelegramAuthSessionRepository telegramAuthSessionRepository; - private final com.launchly.admin.service.UserAuditService userAuditService; - private final com.launchly.bot.repository.BotRepository botRepository; - private final com.launchly.bot.repository.BotMemberRepository botMemberRepository; - private final com.launchly.billing.repository.SubscriptionRepository subscriptionRepository; - private final com.launchly.common.utils.MessageUtils messageUtils; + private final UserAuditService userAuditService; + private final BotService botService; + private final MessageUtils messageUtils; + private final TurnstileService turnstileService; + + public AuthServiceImpl( + UserRepository userRepository, + TokenService tokenService, + AuthMapper authMapper, + PasswordEncoder passwordEncoder, + BillingService billingService, + TelegramAuthSessionRepository telegramAuthSessionRepository, + UserAuditService userAuditService, + @Lazy BotService botService, + MessageUtils messageUtils, + TurnstileService turnstileService) { + this.userRepository = userRepository; + this.tokenService = tokenService; + this.authMapper = authMapper; + this.passwordEncoder = passwordEncoder; + this.billingService = billingService; + this.telegramAuthSessionRepository = telegramAuthSessionRepository; + this.userAuditService = userAuditService; + this.botService = botService; + this.messageUtils = messageUtils; + this.turnstileService = turnstileService; + } @Value("${telegram.system-bot-username:}") private String systemBotUsername; @@ -58,6 +80,10 @@ public class AuthServiceImpl implements AuthService { @Override @Transactional public AuthResponse register(RegisterRequest request) { + if (!turnstileService.verifyToken(request.turnstileToken())) { + throw new AppException(HttpStatus.BAD_REQUEST, messageUtils.getMessage("auth.error.captcha_invalid")); + } + if (userRepository.existsByEmail(request.email())) { throw new AppException(HttpStatus.CONFLICT, messageUtils.getMessage("auth.error.email_already_in_use")); } @@ -91,6 +117,10 @@ public AuthResponse register(RegisterRequest request) { @Override @Transactional public AuthResponse login(LoginRequest request) { + if (!turnstileService.verifyToken(request.turnstileToken())) { + throw new AppException(HttpStatus.BAD_REQUEST, messageUtils.getMessage("auth.error.captcha_invalid")); + } + User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new AppException(HttpStatus.UNAUTHORIZED, messageUtils.getMessage("auth.error.invalid_credentials"))); @@ -335,20 +365,17 @@ public boolean handleTelegramAuth(String token, Long telegramUserId, String tele @Transactional public void deleteUserAccount(Long userId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "User not found")); - - List ownedBots = botRepository.findAllByUserId(userId); - for (Bot b : ownedBots) { - botRepository.delete(b); - } - - List memberships = botMemberRepository.findByUserId(userId); - for (BotMember bm : memberships) { - botMemberRepository.delete(bm); - } - - subscriptionRepository.findByUserId(userId).ifPresent(subscriptionRepository::delete); - + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "auth.error.user_not_found")); + botService.deleteAllUserData(userId); + billingService.deleteSubscription(userId); userRepository.delete(user); } + + @Override + @Transactional + public void deleteAccountByEmail(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "auth.error.user_not_found")); + deleteUserAccount(user.getId()); + } } diff --git a/backend/src/main/java/com/launchly/auth/service/impl/UserQueryServiceImpl.java b/backend/src/main/java/com/launchly/auth/service/impl/UserQueryServiceImpl.java index 3b235f5a..205e5104 100644 --- a/backend/src/main/java/com/launchly/auth/service/impl/UserQueryServiceImpl.java +++ b/backend/src/main/java/com/launchly/auth/service/impl/UserQueryServiceImpl.java @@ -1,5 +1,6 @@ package com.launchly.auth.service.impl; +import com.launchly.auth.entity.Role; import com.launchly.auth.entity.User; import com.launchly.auth.repository.UserRepository; import com.launchly.auth.service.UserQueryService; @@ -8,6 +9,8 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -21,13 +24,13 @@ public class UserQueryServiceImpl implements UserQueryService { @Override public User getUserOrThrow(Long userId) { return userRepository.findById(userId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "User not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "auth.error.user_not_found")); } @Override public User getUserByEmailOrThrow(String email) { return userRepository.findByEmail(email) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "User not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "auth.error.user_not_found")); } @Override @@ -70,6 +73,36 @@ public long countActiveUsers() { return userRepository.countByActiveTrue(); } + @Override + public long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end) { + return userRepository.countByCreatedAtBetween(start, end); + } + + @Override + public long countByRoleInAndCreatedAtBetween(Collection roles, LocalDateTime start, LocalDateTime end) { + return userRepository.countByRoleInAndCreatedAtBetween(roles, start, end); + } + + @Override + public long countByRoleAndCreatedAtBetween(Role role, LocalDateTime start, LocalDateTime end) { + return userRepository.countByRoleAndCreatedAtBetween(role, start, end); + } + + @Override + public long countByRoleIn(Collection roles) { + return userRepository.countByRoleIn(roles); + } + + @Override + public long countByActiveTrueAndUpdatedAtAfter(LocalDateTime date) { + return userRepository.countByActiveTrueAndUpdatedAtAfter(date); + } + + @Override + public List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end) { + return userRepository.findByCreatedAtBetween(start, end); + } + @Override public List findAllUsers() { return userRepository.findAll(); diff --git a/backend/src/main/java/com/launchly/billing/constant/BillingConstants.java b/backend/src/main/java/com/launchly/billing/constant/BillingConstants.java new file mode 100644 index 00000000..9bdd7e37 --- /dev/null +++ b/backend/src/main/java/com/launchly/billing/constant/BillingConstants.java @@ -0,0 +1,10 @@ +package com.launchly.billing.constant; + +public final class BillingConstants { + + private BillingConstants() { + } + + public static final String PLAN_FREE = "FREE"; + public static final String PLAN_PRO = "PRO"; +} diff --git a/backend/src/main/java/com/launchly/billing/controller/BillingController.java b/backend/src/main/java/com/launchly/billing/controller/BillingController.java index b24e2aec..d9ee48db 100644 --- a/backend/src/main/java/com/launchly/billing/controller/BillingController.java +++ b/backend/src/main/java/com/launchly/billing/controller/BillingController.java @@ -1,6 +1,7 @@ package com.launchly.billing.controller; import com.launchly.billing.dto.request.CheckoutRequest; +import com.launchly.billing.dto.request.ConfirmSessionRequest; import com.launchly.billing.dto.response.CheckoutResponse; import com.launchly.billing.dto.response.PlanResponse; import com.launchly.billing.dto.response.SubscriptionResponse; @@ -101,10 +102,9 @@ public ResponseEntity resumeSubscription(@AuthenticationPr @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @PostMapping("/subscription/confirm-session") - public ResponseEntity confirmSession(@RequestBody java.util.Map request, + public ResponseEntity confirmSession(@Valid @RequestBody ConfirmSessionRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { - String sessionId = request.get("sessionId"); - return ResponseEntity.ok(billingService.confirmCheckoutSession(sessionId, userDetails.getId())); + return ResponseEntity.ok(billingService.confirmCheckoutSession(request.sessionId(), userDetails.getId())); } } diff --git a/backend/src/main/java/com/launchly/billing/dto/request/ConfirmSessionRequest.java b/backend/src/main/java/com/launchly/billing/dto/request/ConfirmSessionRequest.java new file mode 100644 index 00000000..65fa6fda --- /dev/null +++ b/backend/src/main/java/com/launchly/billing/dto/request/ConfirmSessionRequest.java @@ -0,0 +1,11 @@ +package com.launchly.billing.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request payload to confirm a Stripe Checkout session") +public record ConfirmSessionRequest( + @Schema(description = "Stripe Checkout session ID", example = "cs_test_1234567890", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "validation.billing.session_id.required") + String sessionId +) {} diff --git a/backend/src/main/java/com/launchly/billing/entity/Plan.java b/backend/src/main/java/com/launchly/billing/entity/Plan.java index 37713377..1bb75c4d 100644 --- a/backend/src/main/java/com/launchly/billing/entity/Plan.java +++ b/backend/src/main/java/com/launchly/billing/entity/Plan.java @@ -6,15 +6,19 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import java.math.BigDecimal; @Entity @Table(name = "plans") -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/billing/entity/Subscription.java b/backend/src/main/java/com/launchly/billing/entity/Subscription.java index f0b86aa0..48d3a192 100644 --- a/backend/src/main/java/com/launchly/billing/entity/Subscription.java +++ b/backend/src/main/java/com/launchly/billing/entity/Subscription.java @@ -14,9 +14,11 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import java.time.LocalDateTime; @Entity @@ -25,8 +27,10 @@ @Index(name = "idx_subscriptions_plan_id", columnList = "plan_id"), @Index(name = "idx_subscriptions_stripe_id", columnList = "stripe_subscription_id") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"user", "plan"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/billing/repository/SubscriptionRepository.java b/backend/src/main/java/com/launchly/billing/repository/SubscriptionRepository.java index 21599686..b941ce9b 100644 --- a/backend/src/main/java/com/launchly/billing/repository/SubscriptionRepository.java +++ b/backend/src/main/java/com/launchly/billing/repository/SubscriptionRepository.java @@ -3,6 +3,8 @@ import com.launchly.billing.entity.Subscription; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.List; import java.util.Optional; public interface SubscriptionRepository extends JpaRepository { @@ -19,4 +21,8 @@ public interface SubscriptionRepository extends JpaRepository findById(Long id); + + @EntityGraph(attributePaths = {"plan", "user"}) + @Query("SELECT s FROM Subscription s") + List findAllWithPlanAndUser(); } diff --git a/backend/src/main/java/com/launchly/billing/service/BillingService.java b/backend/src/main/java/com/launchly/billing/service/BillingService.java index 300c8794..35339cfd 100644 --- a/backend/src/main/java/com/launchly/billing/service/BillingService.java +++ b/backend/src/main/java/com/launchly/billing/service/BillingService.java @@ -14,4 +14,5 @@ public interface BillingService { SubscriptionResponse resumeSubscription(Long userId); SubscriptionResponse confirmCheckoutSession(String sessionId, Long userId); void handleStripeWebhook(String payload, String sigHeader); + void deleteSubscription(Long userId); } diff --git a/backend/src/main/java/com/launchly/billing/service/impl/BillingServiceImpl.java b/backend/src/main/java/com/launchly/billing/service/impl/BillingServiceImpl.java index 02ee9ca2..5ed34d7d 100644 --- a/backend/src/main/java/com/launchly/billing/service/impl/BillingServiceImpl.java +++ b/backend/src/main/java/com/launchly/billing/service/impl/BillingServiceImpl.java @@ -1,10 +1,12 @@ package com.launchly.billing.service.impl; +import com.launchly.common.constant.CacheConstants; import com.launchly.auth.entity.User; import com.launchly.auth.service.UserQueryService; import com.launchly.billing.dto.response.CheckoutResponse; import com.launchly.billing.dto.response.PlanResponse; import com.launchly.billing.dto.response.SubscriptionResponse; +import com.launchly.billing.constant.BillingConstants; import com.launchly.billing.entity.Plan; import com.launchly.billing.entity.Subscription; import com.launchly.billing.entity.SubscriptionStatus; @@ -31,9 +33,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Caching; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -48,6 +53,8 @@ @Slf4j public class BillingServiceImpl implements BillingService { + private static final Duration WEBHOOK_DEDUP_TTL = Duration.ofDays(3); + private final SubscriptionRepository subscriptionRepository; private final PlanRepository planRepository; private final UserQueryService userQueryService; @@ -55,6 +62,7 @@ public class BillingServiceImpl implements BillingService { private final CacheManager cacheManager; private final PlanLimitService planLimitService; private final StringRedisTemplate stringRedisTemplate; + private final TransactionTemplate transactionTemplate; @Value("${stripe.api.key:}") private String apiKey; @@ -83,8 +91,8 @@ public void createFreeSubscription(Long userId) { User user = userQueryService.getUserOrThrow(userId); - Plan freePlan = planRepository.findByName("FREE") - .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "Default FREE plan not found")); + Plan freePlan = planRepository.findByName(BillingConstants.PLAN_FREE) + .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.default_plan_not_found")); Subscription subscription = Subscription.builder() .status(SubscriptionStatus.ACTIVE) @@ -99,7 +107,7 @@ public void createFreeSubscription(Long userId) { @Override @Transactional(readOnly = true) - @Cacheable(value = "plans", key = "'all'") + @Cacheable(value = CacheConstants.PLANS, key = "'all'") public List getAvailablePlans() { return billingMapper.toPlanResponseList( planRepository.findAll().stream().filter(Plan::isActive).toList() @@ -108,47 +116,64 @@ public List getAvailablePlans() { @Override @Transactional - @Cacheable(value = "subscription", key = "#userId") + @Cacheable(value = CacheConstants.SUBSCRIPTION, key = "#userId") public SubscriptionResponse getSubscriptionByUser(Long userId) { Subscription subscription = subscriptionRepository.findByUserId(userId) .orElseGet(() -> { createFreeSubscription(userId); return subscriptionRepository.findByUserId(userId) - .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to resolve subscription")); + .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.resolve_subscription_failed")); }); return billingMapper.toSubscriptionResponse(subscription); } @Override - @Transactional @CircuitBreaker(name = "stripe", fallbackMethod = "createCheckoutSessionFallback") @Retry(name = "stripe") public CheckoutResponse createCheckoutSession(Long planId, Long userId) { - User user = userQueryService.getUserOrThrow(userId); + String[] checkoutInfo = transactionTemplate.execute(status -> { + User user = userQueryService.getUserOrThrow(userId); - Plan plan = planLimitService.getPlan(planId); - if ("FREE".equalsIgnoreCase(plan.getName())) { - throw new AppException(HttpStatus.BAD_REQUEST, "billing.error.cannot_checkout_free"); - } + Plan plan = planLimitService.getPlan(planId); + if (BillingConstants.PLAN_FREE.equalsIgnoreCase(plan.getName())) { + throw new AppException(HttpStatus.BAD_REQUEST, "billing.error.cannot_checkout_free"); + } - Subscription subscription = subscriptionRepository.findByUserId(userId) - .orElseGet(() -> { - createFreeSubscription(userId); - return subscriptionRepository.findByUserId(userId) - .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to resolve subscription")); - }); + Subscription subscription = subscriptionRepository.findByUserId(userId) + .orElseGet(() -> { + createFreeSubscription(userId); + return subscriptionRepository.findByUserId(userId) + .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.resolve_subscription_failed")); + }); + + return new String[]{ + subscription.getStripeCustomerId(), + user.getEmail(), + user.getName(), + plan.getStripePriceId() + }; + }); + + String customerId = checkoutInfo[0]; + String email = checkoutInfo[1]; + String name = checkoutInfo[2]; + String priceId = checkoutInfo[3]; try { - String customerId = subscription.getStripeCustomerId(); if (customerId == null || customerId.isEmpty()) { CustomerCreateParams customerParams = CustomerCreateParams.builder() - .setEmail(user.getEmail()) - .setName(user.getName()) + .setEmail(email) + .setName(name) .build(); Customer customer = Customer.create(customerParams); customerId = customer.getId(); - subscription.setStripeCustomerId(customerId); - subscriptionRepository.save(subscription); + + final String finalCustomerId = customerId; + transactionTemplate.executeWithoutResult(status -> { + Subscription subscription = subscriptionRepository.findByUserId(userId).orElseThrow(); + subscription.setStripeCustomerId(finalCustomerId); + subscriptionRepository.save(subscription); + }); } SessionCreateParams.Builder sessionBuilder = SessionCreateParams.builder() @@ -160,7 +185,7 @@ public CheckoutResponse createCheckoutSession(Long planId, Long userId) { .putMetadata("planId", String.valueOf(planId)) .addLineItem( SessionCreateParams.LineItem.builder() - .setPrice(plan.getStripePriceId()) + .setPrice(priceId) .setQuantity(1L) .build() ); @@ -174,16 +199,15 @@ public CheckoutResponse createCheckoutSession(Long planId, Long userId) { } @Override - @Transactional - @org.springframework.cache.annotation.Caching(evict = { - @CacheEvict(value = "subscription", key = "#userId"), - @CacheEvict(value = "subscription", key = "'plan:' + #userId") + @Caching(evict = { + @CacheEvict(value = CacheConstants.SUBSCRIPTION, key = "#userId"), + @CacheEvict(value = CacheConstants.SUBSCRIPTION, key = "'plan:' + #userId") }) @CircuitBreaker(name = "stripe", fallbackMethod = "cancelSubscriptionFallback") @Retry(name = "stripe") public SubscriptionResponse cancelSubscription(Long userId) { Subscription subscription = subscriptionRepository.findByUserId(userId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Subscription not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "billing.error.subscription_not_found")); String stripeSubId = subscription.getStripeSubscriptionId(); if (stripeSubId == null || stripeSubId.isEmpty()) { @@ -196,11 +220,14 @@ public SubscriptionResponse cancelSubscription(Long userId) { params.put("cancel_at_period_end", true); stripeSub.update(params); - subscription.setCancelAtPeriodEnd(true); - subscription.setStatus(SubscriptionStatus.CANCELLED); - subscription = subscriptionRepository.save(subscription); + Subscription updatedSubscription = transactionTemplate.execute(status -> { + Subscription sub = subscriptionRepository.findByUserId(userId).orElseThrow(); + sub.setCancelAtPeriodEnd(true); + sub.setStatus(SubscriptionStatus.CANCELLED); + return subscriptionRepository.save(sub); + }); - return billingMapper.toSubscriptionResponse(subscription); + return billingMapper.toSubscriptionResponse(updatedSubscription); } catch (Exception e) { log.error("Stripe cancel error for subscriptionId={}: {}", stripeSubId, e.getMessage(), e); throw new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.cancel_failed"); @@ -208,16 +235,15 @@ public SubscriptionResponse cancelSubscription(Long userId) { } @Override - @Transactional - @org.springframework.cache.annotation.Caching(evict = { - @CacheEvict(value = "subscription", key = "#userId"), - @CacheEvict(value = "subscription", key = "'plan:' + #userId") + @Caching(evict = { + @CacheEvict(value = CacheConstants.SUBSCRIPTION, key = "#userId"), + @CacheEvict(value = CacheConstants.SUBSCRIPTION, key = "'plan:' + #userId") }) @CircuitBreaker(name = "stripe", fallbackMethod = "resumeSubscriptionFallback") @Retry(name = "stripe") public SubscriptionResponse resumeSubscription(Long userId) { Subscription subscription = subscriptionRepository.findByUserId(userId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Subscription not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "billing.error.subscription_not_found")); String stripeSubId = subscription.getStripeSubscriptionId(); if (stripeSubId == null || stripeSubId.isEmpty()) { @@ -230,11 +256,14 @@ public SubscriptionResponse resumeSubscription(Long userId) { params.put("cancel_at_period_end", false); stripeSub.update(params); - subscription.setCancelAtPeriodEnd(false); - subscription.setStatus(SubscriptionStatus.ACTIVE); - subscription = subscriptionRepository.save(subscription); + Subscription updatedSubscription = transactionTemplate.execute(status -> { + Subscription sub = subscriptionRepository.findByUserId(userId).orElseThrow(); + sub.setCancelAtPeriodEnd(false); + sub.setStatus(SubscriptionStatus.ACTIVE); + return subscriptionRepository.save(sub); + }); - return billingMapper.toSubscriptionResponse(subscription); + return billingMapper.toSubscriptionResponse(updatedSubscription); } catch (Exception e) { log.error("Stripe resume error for subscriptionId={}: {}", stripeSubId, e.getMessage(), e); throw new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.resume_failed"); @@ -279,20 +308,8 @@ public SubscriptionResponse confirmCheckoutSession(String sessionId, Long userId handleCheckoutCompleted(session); } } catch (Exception e) { - log.error("Error retrieving Stripe Checkout Session {}: {}", sessionId, e.getMessage()); - if (sessionId.startsWith("cs_test")) { - planRepository.findByName("PRO").ifPresent(proPlan -> { - Subscription sub = subscriptionRepository.findByUserId(userId) - .orElseGet(() -> { - createFreeSubscription(userId); - return subscriptionRepository.findByUserId(userId).orElseThrow(); - }); - sub.setPlan(proPlan); - sub.setStatus(SubscriptionStatus.ACTIVE); - subscriptionRepository.save(sub); - evictSubscriptionCache(userId); - }); - } + log.error("Error retrieving Stripe Checkout Session {}: {}", sessionId, e.getMessage(), e); + throw new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.session_retrieval_failed"); } evictSubscriptionCache(userId); @@ -313,7 +330,6 @@ private StripeObject deserializeEventObject(Event event) { } @Override - @Transactional public void handleStripeWebhook(String payload, String sigHeader) { Event event; try { @@ -327,7 +343,7 @@ public void handleStripeWebhook(String payload, String sigHeader) { if (event.getId() != null) { String dedupKey = "stripe:event:" + event.getId(); - Boolean isNew = stringRedisTemplate.opsForValue().setIfAbsent(dedupKey, "1", Duration.ofDays(3)); + Boolean isNew = stringRedisTemplate.opsForValue().setIfAbsent(dedupKey, "1", WEBHOOK_DEDUP_TTL); if (Boolean.FALSE.equals(isNew)) { log.info("Duplicate Stripe webhook event ignored: {}", event.getId()); return; @@ -392,7 +408,7 @@ private void handleCheckoutCompleted(Session session) throws Exception { Long planId = Long.valueOf(planIdStr); Plan plan = planLimitService.getPlan(planId); Subscription subscription = subscriptionRepository.findByUserId(userId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Subscription not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "billing.error.subscription_not_found")); String stripeSubId = session.getSubscription(); com.stripe.model.Subscription stripeSub = com.stripe.model.Subscription.retrieve(stripeSubId); @@ -447,8 +463,8 @@ private void handleSubscriptionDeleted(com.stripe.model.Subscription stripeSub) return; } - Plan freePlan = planRepository.findByName("FREE") - .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "Default FREE plan not found")); + Plan freePlan = planRepository.findByName(BillingConstants.PLAN_FREE) + .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.default_plan_not_found")); subscription.setPlan(freePlan); subscription.setStatus(SubscriptionStatus.ACTIVE); @@ -459,7 +475,7 @@ private void handleSubscriptionDeleted(com.stripe.model.Subscription stripeSub) subscriptionRepository.save(subscription); evictSubscriptionCache(subscription.getUser().getId()); - log.info("Subscription deleted in Stripe. Downgraded user {} to FREE plan", subscription.getUser().getId()); + log.info("Subscription deleted in Stripe. Downgraded user {} to {} plan", subscription.getUser().getId(), BillingConstants.PLAN_FREE); } private void handleSubscriptionUpdated(com.stripe.model.Subscription stripeSub) { @@ -469,9 +485,9 @@ private void handleSubscriptionUpdated(com.stripe.model.Subscription stripeSub) return; } - subscription.setStatus(com.launchly.billing.util.StripeUtils.mapStripeStatus(stripeSub.getStatus())); - subscription.setCurrentPeriodStart(com.launchly.billing.util.StripeUtils.mapEpoch(stripeSub.getCurrentPeriodStart())); - subscription.setCurrentPeriodEnd(com.launchly.billing.util.StripeUtils.mapEpoch(stripeSub.getCurrentPeriodEnd())); + subscription.setStatus(StripeUtils.mapStripeStatus(stripeSub.getStatus())); + subscription.setCurrentPeriodStart(StripeUtils.mapEpoch(stripeSub.getCurrentPeriodStart())); + subscription.setCurrentPeriodEnd(StripeUtils.mapEpoch(stripeSub.getCurrentPeriodEnd())); subscription.setCancelAtPeriodEnd(stripeSub.getCancelAtPeriodEnd()); subscriptionRepository.save(subscription); @@ -482,11 +498,17 @@ private void handleSubscriptionUpdated(com.stripe.model.Subscription stripeSub) private void evictSubscriptionCache(Long userId) { if (userId != null) { - org.springframework.cache.Cache cache = cacheManager.getCache("subscription"); + Cache cache = cacheManager.getCache("subscription"); if (cache != null) { cache.evict(userId); cache.evict("plan:" + userId); } } } + + @Override + @Transactional + public void deleteSubscription(Long userId) { + subscriptionRepository.findByUserId(userId).ifPresent(subscriptionRepository::delete); + } } diff --git a/backend/src/main/java/com/launchly/billing/service/impl/PlanLimitServiceImpl.java b/backend/src/main/java/com/launchly/billing/service/impl/PlanLimitServiceImpl.java index 6193c864..e02610e7 100644 --- a/backend/src/main/java/com/launchly/billing/service/impl/PlanLimitServiceImpl.java +++ b/backend/src/main/java/com/launchly/billing/service/impl/PlanLimitServiceImpl.java @@ -1,5 +1,6 @@ package com.launchly.billing.service.impl; +import com.launchly.bot.constant.BotConstants; import com.launchly.bot.entity.Bot; import com.launchly.common.utils.EncryptionUtil; import com.launchly.billing.entity.Plan; @@ -14,6 +15,7 @@ import com.launchly.broadcast.repository.BroadcastCampaignRepository; import com.launchly.common.exception.AppException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -23,6 +25,7 @@ import java.time.LocalDateTime; import java.util.List; +@Slf4j @Service @RequiredArgsConstructor public class PlanLimitServiceImpl implements PlanLimitService { @@ -42,8 +45,8 @@ public void checkBotLimit(Long userId, String newTelegramToken) { List userBots = botRepository.findAllByUserId(userId); boolean tokenAlreadyExists = false; - if (newTelegramToken != null && !"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(newTelegramToken)) { - for (com.launchly.bot.entity.Bot b : userBots) { + if (newTelegramToken != null && !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(newTelegramToken)) { + for (Bot b : userBots) { try { String decrypted = encryptionUtil.decrypt(b.getTelegramToken()); if (newTelegramToken.equals(decrypted)) { @@ -51,6 +54,7 @@ public void checkBotLimit(Long userId, String newTelegramToken) { break; } } catch (Exception e) { + log.warn("Failed to decrypt token for bot id={}: {}", b.getId(), e.getMessage()); } } } @@ -66,7 +70,7 @@ public void checkBotLimit(Long userId, String newTelegramToken) { return b.getTelegramToken(); } }) - .filter(token -> !"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(token)) + .filter(token -> !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(token)) .distinct() .count(); @@ -123,7 +127,7 @@ public Plan getActivePlan(Long userId) { if (userRepository != null && userRepository.findById(userId).map(u -> u.getRole() == Role.ROLE_ADMIN).orElse(false)) { return (Plan) Hibernate.unproxy(planRepository.findByName("ENTERPRISE") .orElseGet(() -> planRepository.findByName("FREE") - .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "Default FREE plan not found")))); + .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.default_plan_not_found")))); } Plan plan = subscriptionRepository.findByUserId(userId) @@ -138,7 +142,7 @@ public Plan getActivePlan(Long userId) { }) .map(Subscription::getPlan) .orElseGet(() -> planRepository.findByName("FREE") - .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "Default FREE plan not found"))); + .orElseThrow(() -> new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "billing.error.default_plan_not_found"))); return (Plan) Hibernate.unproxy(plan); } @@ -147,7 +151,7 @@ public Plan getActivePlan(Long userId) { @Cacheable(value = "plan", key = "#planId") public Plan getPlan(Long planId) { Plan plan = planRepository.findById(planId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Plan not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "billing.error.plan_not_found")); return (Plan) Hibernate.unproxy(plan); } } diff --git a/backend/src/main/java/com/launchly/blog/entity/BlogArticle.java b/backend/src/main/java/com/launchly/blog/entity/BlogArticle.java index 1fef409e..8a9de96f 100644 --- a/backend/src/main/java/com/launchly/blog/entity/BlogArticle.java +++ b/backend/src/main/java/com/launchly/blog/entity/BlogArticle.java @@ -7,12 +7,18 @@ import jakarta.persistence.Version; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; @Entity @Table(name = "blog_articles") -@Data +@Getter +@Setter +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor @@ -22,6 +28,7 @@ public class BlogArticle { private Long version; @Id + @EqualsAndHashCode.Include private String id; @Column(nullable = false, length = 500) diff --git a/backend/src/main/java/com/launchly/blog/repository/BlogArticleRepository.java b/backend/src/main/java/com/launchly/blog/repository/BlogArticleRepository.java index 343039a6..e0fcebf3 100644 --- a/backend/src/main/java/com/launchly/blog/repository/BlogArticleRepository.java +++ b/backend/src/main/java/com/launchly/blog/repository/BlogArticleRepository.java @@ -4,8 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface BlogArticleRepository extends JpaRepository { List findByLanguageIgnoreCase(String language); + + Optional findByIdIgnoreCase(String id); } diff --git a/backend/src/main/java/com/launchly/blog/service/impl/AdminBlogServiceImpl.java b/backend/src/main/java/com/launchly/blog/service/impl/AdminBlogServiceImpl.java index 9cdb01e4..1ca86e71 100644 --- a/backend/src/main/java/com/launchly/blog/service/impl/AdminBlogServiceImpl.java +++ b/backend/src/main/java/com/launchly/blog/service/impl/AdminBlogServiceImpl.java @@ -162,9 +162,7 @@ public void deleteArticle(String id) { return; } BlogArticle article = blogArticleRepository.findById(cleanId) - .orElseGet(() -> blogArticleRepository.findAll().stream() - .filter(a -> a.getId() != null && a.getId().trim().equalsIgnoreCase(cleanId)) - .findFirst() + .orElseGet(() -> blogArticleRepository.findByIdIgnoreCase(cleanId) .orElse(null)); if (article != null) { blogArticleRepository.delete(article); @@ -174,9 +172,7 @@ public void deleteArticle(String id) { private BlogArticle findArticleOrThrow(String id) { String cleanId = id != null ? id.trim() : ""; return blogArticleRepository.findById(cleanId) - .orElseGet(() -> blogArticleRepository.findAll().stream() - .filter(a -> a.getId() != null && a.getId().equalsIgnoreCase(cleanId)) - .findFirst() + .orElseGet(() -> blogArticleRepository.findByIdIgnoreCase(cleanId) .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, messageUtils.getMessage("common.error.not_found")))); } } diff --git a/backend/src/main/java/com/launchly/bot/constant/BotConstants.java b/backend/src/main/java/com/launchly/bot/constant/BotConstants.java new file mode 100644 index 00000000..12b83b2e --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/constant/BotConstants.java @@ -0,0 +1,16 @@ +package com.launchly.bot.constant; + +public final class BotConstants { + + private BotConstants() {} + + public static final String DUMMY_TOKEN_PLACEHOLDER = "0000000000:dummyTokenPlaceholderForNoBotConfig"; + public static final String DEFAULT_AUTOMATION_NAME = "Автоматизація"; + public static final int MAX_FLOW_ITERATIONS = 50; + public static final String ROLE_OWNER = "Owner"; + public static final String ROLE_VIEWER = "Viewer"; + public static final String ROLE_ADMIN = "Admin"; + public static final String ROLE_EDITOR = "Editor"; + public static final Long SYSTEM_BOT_ID = -1L; + public static final int DEFAULT_DELAY_SECONDS = 3; +} diff --git a/backend/src/main/java/com/launchly/bot/constant/ModerationConstants.java b/backend/src/main/java/com/launchly/bot/constant/ModerationConstants.java new file mode 100644 index 00000000..f5cba947 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/constant/ModerationConstants.java @@ -0,0 +1,65 @@ +package com.launchly.bot.constant; + +import java.util.List; +import java.util.regex.Pattern; + +public final class ModerationConstants { + + private ModerationConstants() {} + + public static final String GLOBAL_CHAT_ID = "*"; + + public static final String MSG_KEY_DEFAULT_WARNING_TEMPLATE = "bot.moderation.default_warning_template"; + public static final String MSG_KEY_DEFAULT_USER = "bot.moderation.default_user"; + public static final String MSG_KEY_REASON_ANTI_FORWARD = "bot.moderation.reason.anti_forward"; + public static final String MSG_KEY_REASON_TEXT_ONLY = "bot.moderation.reason.text_only"; + public static final String MSG_KEY_REASON_MEDIA_ONLY = "bot.moderation.reason.media_only"; + public static final String MSG_KEY_REASON_ANTI_LINK = "bot.moderation.reason.anti_link"; + public static final String MSG_KEY_REASON_STOP_WORD = "bot.moderation.reason.stop_word"; + + public static final String MSG_KEY_CAPTCHA_DEFAULT_MESSAGE = "bot.moderation.captcha.default_message"; + public static final String MSG_KEY_CAPTCHA_BUTTON_HUMAN = "bot.moderation.captcha.button_human"; + public static final String MSG_KEY_CAPTCHA_NOT_FOR_YOU = "bot.moderation.captcha.not_for_you"; + public static final String MSG_KEY_CAPTCHA_SUCCESS = "bot.moderation.captcha.success"; + public static final String MSG_KEY_CAPTCHA_FAIL = "bot.moderation.captcha.fail"; + + public static final String DEFAULT_WARNING_TEMPLATE = "{first_name}, your message was deleted due to a violation of chat rules!"; + public static final String DEFAULT_CAPTCHA_MESSAGE_TEMPLATE = "{name}, please confirm you are human within {timeout}s to chat!"; + public static final String DEFAULT_CAPTCHA_BUTTON_LABEL = "I am human"; + public static final String DEFAULT_CAPTCHA_NOT_FOR_YOU = "This verification is not intended for you!"; + public static final String DEFAULT_CAPTCHA_SUCCESS = "Verification passed! Welcome to the group!"; + public static final String DEFAULT_CAPTCHA_FAIL = "Incorrect answer. Please try again!"; + public static final String DEFAULT_USER_NAME = "User"; + public static final String DEFAULT_PASS_VARIABLE = "is_moderation_passed"; + public static final String DEFAULT_REASON_VARIABLE = "moderation_violation_reasons"; + + public static final String CB_CAPTCHA_PREFIX = "mod_captcha:"; + public static final int DEFAULT_CAPTCHA_TIMEOUT_SECONDS = 60; + public static final int DEFAULT_WARN_TTL_SECONDS = 5; + public static final int DEFAULT_WARN_AUTO_DELETE_SECONDS = 10; + public static final int DEFAULT_MUTE_DURATION_MINUTES = 60; + public static final int DEFAULT_MUTE_DURATION_SECONDS = 3600; + public static final int SECONDS_PER_MINUTE = 60; + public static final long MILLIS_PER_SECOND = 1000L; + + public static final String CODE_ANTI_FORWARD = "Anti-Forward"; + public static final String CODE_TEXT_ONLY = "Text-Only"; + public static final String CODE_MEDIA_ONLY = "Media-Only"; + public static final String CODE_ANTI_LINK_PREFIX = "Anti-Link: "; + public static final String CODE_STOP_WORD_PREFIX = "Stop-Word: "; + + public static final Pattern URL_PATTERN = Pattern.compile( + "(?i)\\b(https?://|www\\.|t\\.me/|telegram\\.me/)[^\\s]+" + ); + + public static final List DEFAULT_SCAM_PATTERNS = List.of( + "100x", "pump and dump", "free giveaway", "роздача крипти", "сигнали крипта", + "заробіток без вкладень", "казино", "casino", "1win", "vulkan", "ставки на спорт", + "free spins", "crypto signals", "airdrop claim", "найкращий заробіток", "інвестиції під 100%" + ); + + public static final List DEFAULT_PROFANITY_PATTERNS = List.of( + "хуй", "бляд", "сука", "єбат", "ебат", "пизд", "пізд", "мудак", "гандон", + "fuck", "bitch", "cunt", "asshole", "dick", "shit" + ); +} diff --git a/backend/src/main/java/com/launchly/bot/constant/TelegramConstants.java b/backend/src/main/java/com/launchly/bot/constant/TelegramConstants.java new file mode 100644 index 00000000..83efed2c --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/constant/TelegramConstants.java @@ -0,0 +1,16 @@ +package com.launchly.bot.constant; + +public final class TelegramConstants { + + private TelegramConstants() { + } + + public static final String API_BASE_URL = "https://api.telegram.org/"; + public static final String BOT_API_URL = API_BASE_URL + "bot"; + public static final String FILE_API_URL = API_BASE_URL + "file/bot"; + public static final String TELEGRAM_DEEP_LINK = "https://t.me/"; + public static final String TELEGRAM_USER_LINK = "tg://user?id="; + public static final String GET_ME_URL_TEMPLATE = BOT_API_URL + "%s/getMe"; + public static final String DELETE_WEBHOOK_URL_TEMPLATE = BOT_API_URL + "%s/deleteWebhook?drop_pending_updates=false"; + public static final String FILE_DOWNLOAD_URL_TEMPLATE = FILE_API_URL + "%s/%s"; +} diff --git a/backend/src/main/java/com/launchly/bot/controller/BotModerationController.java b/backend/src/main/java/com/launchly/bot/controller/BotModerationController.java new file mode 100644 index 00000000..5776edcb --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/controller/BotModerationController.java @@ -0,0 +1,44 @@ +package com.launchly.bot.controller; + +import com.launchly.bot.dto.moderation.BotModerationRuleDto; +import com.launchly.bot.dto.moderation.TestModerationRequest; +import com.launchly.bot.dto.moderation.TestModerationResponse; +import com.launchly.bot.dto.moderation.UpdateBotModerationRuleRequest; +import com.launchly.bot.service.BotModerationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Bot: Content Moderation", description = "Anti-spam, anti-forward, anti-link, media mode, and stop-words moderation management") +@RestController +@RequestMapping("/api/v1/bots/{botId}/moderation") +@RequiredArgsConstructor +public class BotModerationController { + + private final BotModerationService moderationService; + + @Operation(summary = "Get bot moderation settings") + @GetMapping + public ResponseEntity getModerationSettings(@PathVariable Long botId) { + return ResponseEntity.ok(moderationService.getModerationSettings(botId)); + } + + @Operation(summary = "Update bot moderation settings") + @PutMapping + public ResponseEntity updateModerationSettings( + @PathVariable Long botId, + @Valid @RequestBody UpdateBotModerationRuleRequest request) { + return ResponseEntity.ok(moderationService.updateModerationSettings(botId, request)); + } + + @Operation(summary = "Test text and content against moderation rules") + @PostMapping("/test") + public ResponseEntity testModeration( + @PathVariable Long botId, + @Valid @RequestBody TestModerationRequest request) { + return ResponseEntity.ok(moderationService.testModeration(botId, request)); + } +} diff --git a/backend/src/main/java/com/launchly/bot/controller/TemplateController.java b/backend/src/main/java/com/launchly/bot/controller/TemplateController.java index 5a2bb234..01481472 100644 --- a/backend/src/main/java/com/launchly/bot/controller/TemplateController.java +++ b/backend/src/main/java/com/launchly/bot/controller/TemplateController.java @@ -1,5 +1,6 @@ package com.launchly.bot.controller; +import jakarta.validation.Valid; import com.launchly.bot.dto.request.CreateTemplateRequest; import com.launchly.bot.dto.request.UpdateTemplateRequest; import com.launchly.bot.dto.response.TemplateResponse; @@ -42,7 +43,7 @@ public class TemplateController { @PostMapping @Idempotent public ResponseEntity createTemplate( - @RequestBody CreateTemplateRequest request, + @Valid @RequestBody CreateTemplateRequest request, @AuthenticationPrincipal CustomUserDetails userDetails ) { return ResponseEntity.status(HttpStatus.CREATED) @@ -105,7 +106,7 @@ public ResponseEntity trackView( @PutMapping("/{shareCode}") public ResponseEntity updateTemplate( @Parameter(description = "Template unique share code") @PathVariable String shareCode, - @RequestBody UpdateTemplateRequest request, + @Valid @RequestBody UpdateTemplateRequest request, @AuthenticationPrincipal CustomUserDetails userDetails ) { return ResponseEntity.ok(templateService.updateTemplate(shareCode, request, userDetails.getId())); diff --git a/backend/src/main/java/com/launchly/bot/dto/moderation/BotModerationRuleDto.java b/backend/src/main/java/com/launchly/bot/dto/moderation/BotModerationRuleDto.java new file mode 100644 index 00000000..b1a51329 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/dto/moderation/BotModerationRuleDto.java @@ -0,0 +1,34 @@ +package com.launchly.bot.dto.moderation; + +import com.launchly.bot.entity.CaptchaMode; +import com.launchly.bot.entity.MediaMode; +import com.launchly.bot.entity.ViolationAction; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BotModerationRuleDto { + private Long id; + private Long botId; + private String chatId; + private Integer threadId; + private boolean enabled; + private boolean antiForwardEnabled; + private boolean antiLinkEnabled; + private String allowedLinks; + private String stopWords; + private boolean defaultProfanityFilter; + private MediaMode mediaMode; + private ViolationAction actionOnViolation; + private String warningTemplate; + private Integer warnTtlSeconds; + private boolean captchaEnabled; + private CaptchaMode captchaMode; + private Integer captchaTimeoutSeconds; + private String captchaMessageTemplate; +} diff --git a/backend/src/main/java/com/launchly/bot/dto/moderation/TestModerationRequest.java b/backend/src/main/java/com/launchly/bot/dto/moderation/TestModerationRequest.java new file mode 100644 index 00000000..4042db62 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/dto/moderation/TestModerationRequest.java @@ -0,0 +1,16 @@ +package com.launchly.bot.dto.moderation; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TestModerationRequest { + private String text; + private boolean forwarded; + private boolean hasMedia; +} diff --git a/backend/src/main/java/com/launchly/bot/dto/moderation/TestModerationResponse.java b/backend/src/main/java/com/launchly/bot/dto/moderation/TestModerationResponse.java new file mode 100644 index 00000000..9cbdc04a --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/dto/moderation/TestModerationResponse.java @@ -0,0 +1,18 @@ +package com.launchly.bot.dto.moderation; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TestModerationResponse { + private boolean violated; + private List reasons; + private String matchedStopWord; +} diff --git a/backend/src/main/java/com/launchly/bot/dto/moderation/UpdateBotModerationRuleRequest.java b/backend/src/main/java/com/launchly/bot/dto/moderation/UpdateBotModerationRuleRequest.java new file mode 100644 index 00000000..83cd1bf1 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/dto/moderation/UpdateBotModerationRuleRequest.java @@ -0,0 +1,35 @@ +package com.launchly.bot.dto.moderation; + +import com.launchly.bot.entity.CaptchaMode; +import com.launchly.bot.entity.MediaMode; +import com.launchly.bot.entity.ViolationAction; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateBotModerationRuleRequest { + private String chatId; + private Integer threadId; + private boolean enabled; + private boolean antiForwardEnabled; + private boolean antiLinkEnabled; + private String allowedLinks; + private String stopWords; + private boolean defaultProfanityFilter; + @NotNull + private MediaMode mediaMode; + @NotNull + private ViolationAction actionOnViolation; + private String warningTemplate; + private Integer warnTtlSeconds; + private boolean captchaEnabled; + private CaptchaMode captchaMode; + private Integer captchaTimeoutSeconds; + private String captchaMessageTemplate; +} diff --git a/backend/src/main/java/com/launchly/bot/dto/request/BotUpdateRequest.java b/backend/src/main/java/com/launchly/bot/dto/request/BotUpdateRequest.java index 9a2a1d74..cde02c4e 100644 --- a/backend/src/main/java/com/launchly/bot/dto/request/BotUpdateRequest.java +++ b/backend/src/main/java/com/launchly/bot/dto/request/BotUpdateRequest.java @@ -1,5 +1,6 @@ package com.launchly.bot.dto.request; +import com.launchly.bot.entity.BotResponseMode; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "Request payload to update Telegram bot settings") @@ -20,6 +21,9 @@ public record BotUpdateRequest( String telegramToken, @Schema(description = "Bot ID to copy token from", example = "3") - Long copyTokenFromBotId + Long copyTokenFromBotId, + + @Schema(description = "Chat response mode: ALL, PRIVATE_ONLY, GROUPS_ONLY", example = "ALL") + BotResponseMode responseMode ) {} diff --git a/backend/src/main/java/com/launchly/bot/dto/request/InviteMemberRequest.java b/backend/src/main/java/com/launchly/bot/dto/request/InviteMemberRequest.java index be78baf3..165d3434 100644 --- a/backend/src/main/java/com/launchly/bot/dto/request/InviteMemberRequest.java +++ b/backend/src/main/java/com/launchly/bot/dto/request/InviteMemberRequest.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; @Schema(description = "Request payload to invite a team member to collaborate on a bot") public record InviteMemberRequest( @@ -11,8 +12,9 @@ public record InviteMemberRequest( @Email(message = "Invalid email format") String email, - @Schema(description = "Bot member role: ADMIN, EDITOR, VIEWER, SUPPORT", example = "EDITOR", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "Bot member role: ADMIN, EDITOR, VIEWER", example = "EDITOR", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "Role is required") + @Pattern(regexp = "^(?i)(ADMIN|EDITOR|VIEWER)$", message = "Invalid role. Allowed values: ADMIN, EDITOR, VIEWER") String role, @Schema(description = "Grant Live Chat / Inbox management access", example = "true") @@ -21,4 +23,3 @@ public record InviteMemberRequest( @Schema(description = "Grant billing management permissions", example = "false") boolean billingPermission ) {} - diff --git a/backend/src/main/java/com/launchly/bot/dto/request/UpdateMemberRequest.java b/backend/src/main/java/com/launchly/bot/dto/request/UpdateMemberRequest.java index aaea2372..a0e22a27 100644 --- a/backend/src/main/java/com/launchly/bot/dto/request/UpdateMemberRequest.java +++ b/backend/src/main/java/com/launchly/bot/dto/request/UpdateMemberRequest.java @@ -2,11 +2,13 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; @Schema(description = "Request payload to update team member role and permissions on a bot") public record UpdateMemberRequest( - @Schema(description = "Updated role: ADMIN, EDITOR, VIEWER, SUPPORT", example = "ADMIN", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "Updated role: ADMIN, EDITOR, VIEWER", example = "ADMIN", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "Role is required") + @Pattern(regexp = "^(?i)(ADMIN|EDITOR|VIEWER)$", message = "Invalid role. Allowed values: ADMIN, EDITOR, VIEWER") String role, @Schema(description = "Grant Live Chat / Inbox management access", example = "true") @@ -15,4 +17,3 @@ public record UpdateMemberRequest( @Schema(description = "Grant billing management permissions", example = "false") boolean billingPermission ) {} - diff --git a/backend/src/main/java/com/launchly/bot/dto/response/BotDetailResponse.java b/backend/src/main/java/com/launchly/bot/dto/response/BotDetailResponse.java index 71149003..0952b07f 100644 --- a/backend/src/main/java/com/launchly/bot/dto/response/BotDetailResponse.java +++ b/backend/src/main/java/com/launchly/bot/dto/response/BotDetailResponse.java @@ -1,5 +1,6 @@ package com.launchly.bot.dto.response; +import com.launchly.bot.entity.BotResponseMode; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -39,6 +40,9 @@ public record BotDetailResponse( boolean isTemplate, @Schema(description = "Template name if installed from a template") - String templateName + String templateName, + + @Schema(description = "Chat response mode: ALL, PRIVATE_ONLY, GROUPS_ONLY", example = "ALL") + BotResponseMode responseMode ) {} diff --git a/backend/src/main/java/com/launchly/bot/dto/response/BotResponse.java b/backend/src/main/java/com/launchly/bot/dto/response/BotResponse.java index c8695bef..ac5bef34 100644 --- a/backend/src/main/java/com/launchly/bot/dto/response/BotResponse.java +++ b/backend/src/main/java/com/launchly/bot/dto/response/BotResponse.java @@ -1,5 +1,6 @@ package com.launchly.bot.dto.response; +import com.launchly.bot.entity.BotResponseMode; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -54,6 +55,9 @@ public record BotResponse( String templateName, @Schema(description = "Total flow execution runs count", example = "1580") - int runs + int runs, + + @Schema(description = "Chat response mode: ALL, PRIVATE_ONLY, GROUPS_ONLY", example = "ALL") + BotResponseMode responseMode ) {} diff --git a/backend/src/main/java/com/launchly/bot/dto/response/BotUserResponse.java b/backend/src/main/java/com/launchly/bot/dto/response/BotUserResponse.java index dddb3d8e..5f6f0e03 100644 --- a/backend/src/main/java/com/launchly/bot/dto/response/BotUserResponse.java +++ b/backend/src/main/java/com/launchly/bot/dto/response/BotUserResponse.java @@ -34,6 +34,12 @@ public record BotUserResponse( List tags, @Schema(description = "Subscribed timestamp") - LocalDateTime createdAt + LocalDateTime createdAt, + + @Schema(description = "Bot / Automation ID", example = "1") + Long botId, + + @Schema(description = "Bot / Automation Name", example = "Sales Bot") + String botName ) {} diff --git a/backend/src/main/java/com/launchly/bot/dto/response/FlowSchemaResponse.java b/backend/src/main/java/com/launchly/bot/dto/response/FlowSchemaResponse.java index 76c7173c..705bbfb9 100644 --- a/backend/src/main/java/com/launchly/bot/dto/response/FlowSchemaResponse.java +++ b/backend/src/main/java/com/launchly/bot/dto/response/FlowSchemaResponse.java @@ -14,6 +14,16 @@ public record FlowSchemaResponse( Object nodes, @Schema(description = "JSON array of visual connection edges") - Object edges -) {} + Object edges, + + @Schema(description = "JSON array of published visual nodes") + Object publishedNodes, + + @Schema(description = "JSON array of published visual connection edges") + Object publishedEdges +) { + public FlowSchemaResponse(Long id, int version, Object nodes, Object edges) { + this(id, version, nodes, edges, nodes, edges); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/ActionContactManager.java b/backend/src/main/java/com/launchly/bot/engine/action/ActionContactManager.java new file mode 100644 index 00000000..6eef890b --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/ActionContactManager.java @@ -0,0 +1,239 @@ +package com.launchly.bot.engine.action; + +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.BotDialogStateService; +import com.launchly.common.utils.SanitizationUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ActionContactManager { + + private static final int MAX_FIELD_NAME_LENGTH = 50; + private static final int MAX_CUSTOM_FIELDS_COUNT = 100; + private static final int MAX_CUSTOM_FIELD_VALUE_LENGTH = 2000; + + private static final String DEFAULT_CHAT_SCOPE = "private"; + private static final String KEYWORD_COOLDOWN = "cooldown"; + + private static final String KEY_COOLDOWNS = "cooldowns"; + private static final String KEY_CUSTOM_FIELDS = "customFields"; + private static final String KEY_CHAT_CUSTOM_FIELDS = "chatCustomFields"; + + private static final String FIELD_FIRST_NAME = "first_name"; + private static final String FIELD_FIRST_NAME_LABEL = "First Name"; + private static final String FIELD_LAST_NAME = "last_name"; + private static final String FIELD_LAST_NAME_LABEL = "Last Name"; + private static final String FIELD_USERNAME = "username"; + private static final String FIELD_TELEGRAM_USERNAME = "telegram_username"; + private static final String FIELD_TELEGRAM_USERNAME_LABEL = "Telegram Username"; + private static final String FIELD_PHONE = "phone"; + private static final String FIELD_PHONE_LABEL = "Phone"; + private static final String FIELD_EMAIL = "email"; + private static final String FIELD_EMAIL_LABEL = "Email"; + + private final BotUserRepository botUserRepository; + private final BotDialogStateService stateService; + private final ObjectMapper objectMapper; + + @SuppressWarnings("unchecked") + public void updateContactCooldown(BotUser botUser, String cooldownKey, String timestampStr) { + try { + Map metaMap = new HashMap<>(); + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + try { + metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); + } catch (Exception e) { + log.error("Failed to parse metadata: {}", e.getMessage()); + } + } + + Map cooldowns = (Map) metaMap.get(KEY_COOLDOWNS); + if (cooldowns == null) { + cooldowns = new HashMap<>(); + } + if (timestampStr == null) { + cooldowns.remove(cooldownKey); + } else { + cooldowns.put(cooldownKey, timestampStr); + } + metaMap.put(KEY_COOLDOWNS, cooldowns); + + cleanupCooldownsFromCustomFields(metaMap); + + botUser.setMetadata(objectMapper.writeValueAsString(metaMap)); + botUserRepository.saveAndFlush(botUser); + } catch (Exception e) { + log.error("Failed to update contact cooldown: {}", e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + public void updateContactCustomField(BotUser botUser, String fieldName, String fieldValue) { + updateContactCustomField(botUser, DEFAULT_CHAT_SCOPE, fieldName, fieldValue); + } + + @SuppressWarnings("unchecked") + public void updateContactCustomField(BotUser botUser, String chatScope, String fieldName, String fieldValue) { + if (fieldName == null || fieldName.trim().isEmpty()) return; + String cleanFieldName = fieldName.trim(); + if (cleanFieldName.length() > MAX_FIELD_NAME_LENGTH) { + cleanFieldName = cleanFieldName.substring(0, MAX_FIELD_NAME_LENGTH); + } + cleanFieldName = cleanFieldName.replaceAll("[^a-zA-Z0-9_\\-\\.]", ""); + if (cleanFieldName.isEmpty()) return; + + if (cleanFieldName.toLowerCase().contains(KEYWORD_COOLDOWN)) { + String scopedKey = (chatScope != null && !chatScope.isEmpty() && !DEFAULT_CHAT_SCOPE.equalsIgnoreCase(chatScope)) + ? chatScope + "_" + cleanFieldName + : cleanFieldName; + updateContactCooldown(botUser, scopedKey, fieldValue); + return; + } + + String sanitizedValue = fieldValue != null ? fieldValue.trim() : null; + if (sanitizedValue != null) { + if (sanitizedValue.length() > MAX_CUSTOM_FIELD_VALUE_LENGTH) { + sanitizedValue = sanitizedValue.substring(0, MAX_CUSTOM_FIELD_VALUE_LENGTH); + } + sanitizedValue = SanitizationUtil.sanitizeForTelegram(sanitizedValue); + } + + try { + Map metaMap = new HashMap<>(); + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + try { + metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); + } catch (Exception e) { + log.error("Failed to parse metadata: {}", e.getMessage()); + } + } + + cleanupCooldownsFromCustomFields(metaMap); + + if (sanitizedValue == null || sanitizedValue.trim().isEmpty()) { + Map customFields = (Map) metaMap.get(KEY_CUSTOM_FIELDS); + if (customFields != null) { + customFields.remove(cleanFieldName); + metaMap.put(KEY_CUSTOM_FIELDS, customFields); + } + Map chatCustomFields = (Map) metaMap.get(KEY_CHAT_CUSTOM_FIELDS); + if (chatCustomFields != null) { + for (Object groupObj : chatCustomFields.values()) { + if (groupObj instanceof Map groupMap) { + ((Map) groupMap).remove(cleanFieldName); + } + } + metaMap.put(KEY_CHAT_CUSTOM_FIELDS, chatCustomFields); + } + } else if (chatScope != null && !chatScope.isEmpty() && !DEFAULT_CHAT_SCOPE.equalsIgnoreCase(chatScope)) { + Map chatCustomFields = (Map) metaMap.get(KEY_CHAT_CUSTOM_FIELDS); + if (chatCustomFields == null) { + chatCustomFields = new HashMap<>(); + } + Map groupFields = (Map) chatCustomFields.get(chatScope); + if (groupFields == null) { + groupFields = new HashMap<>(); + } + if (groupFields.size() < MAX_CUSTOM_FIELDS_COUNT || groupFields.containsKey(cleanFieldName)) { + groupFields.put(cleanFieldName, sanitizedValue); + } + chatCustomFields.put(chatScope, groupFields); + metaMap.put(KEY_CHAT_CUSTOM_FIELDS, chatCustomFields); + } else { + Map customFields = (Map) metaMap.get(KEY_CUSTOM_FIELDS); + if (customFields == null) { + customFields = new HashMap<>(); + } + if (customFields.size() < MAX_CUSTOM_FIELDS_COUNT || customFields.containsKey(cleanFieldName)) { + customFields.put(cleanFieldName, sanitizedValue); + } + metaMap.put(KEY_CUSTOM_FIELDS, customFields); + } + + botUser.setMetadata(objectMapper.writeValueAsString(metaMap)); + botUserRepository.saveAndFlush(botUser); + } catch (Exception e) { + log.error("Failed to update contact custom field: {}", e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + public void updateContactMetadataField(BotUser botUser, String key, Object value) { + try { + Map metaMap = new HashMap<>(); + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + try { + metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); + } catch (Exception e) { + log.error("Failed to parse metadata: {}", e.getMessage()); + } + } + cleanupCooldownsFromCustomFields(metaMap); + metaMap.put(key, value); + botUser.setMetadata(objectMapper.writeValueAsString(metaMap)); + botUserRepository.saveAndFlush(botUser); + } catch (Exception e) { + log.error("Failed to update contact metadata field: {}", e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + private void cleanupCooldownsFromCustomFields(Map metaMap) { + if (metaMap == null) return; + + Object cfObj = metaMap.get(KEY_CUSTOM_FIELDS); + if (cfObj instanceof Map cfMap) { + Map customFields = new HashMap<>((Map) cfMap); + customFields.keySet().removeIf(k -> k != null && k.toLowerCase().contains(KEYWORD_COOLDOWN)); + metaMap.put(KEY_CUSTOM_FIELDS, customFields); + } + + Object ccfObj = metaMap.get(KEY_CHAT_CUSTOM_FIELDS); + if (ccfObj instanceof Map ccfMap) { + Map chatCustomFields = new HashMap<>((Map) ccfMap); + for (Map.Entry entry : chatCustomFields.entrySet()) { + if (entry.getValue() instanceof Map groupMap) { + Map cleanedGroup = new HashMap<>((Map) groupMap); + cleanedGroup.keySet().removeIf(k -> k != null && k.toLowerCase().contains(KEYWORD_COOLDOWN)); + entry.setValue(cleanedGroup); + } + } + metaMap.put(KEY_CHAT_CUSTOM_FIELDS, chatCustomFields); + } + } + + public void setContactField(BotUser botUser, Long botId, Long telegramUserId, String fieldName, String value, Map sessionData) { + String trimmed = fieldName.trim(); + if (trimmed.equalsIgnoreCase(FIELD_FIRST_NAME) || trimmed.equalsIgnoreCase(FIELD_FIRST_NAME_LABEL)) { + botUser.setFirstName(value); + botUserRepository.save(botUser); + } else if (trimmed.equalsIgnoreCase(FIELD_LAST_NAME) || trimmed.equalsIgnoreCase(FIELD_LAST_NAME_LABEL)) { + botUser.setLastName(value); + botUserRepository.save(botUser); + } else if (trimmed.equalsIgnoreCase(FIELD_USERNAME) || trimmed.equalsIgnoreCase(FIELD_TELEGRAM_USERNAME) || trimmed.equalsIgnoreCase(FIELD_TELEGRAM_USERNAME_LABEL)) { + botUser.setUsername(value); + botUserRepository.save(botUser); + } else if (trimmed.equalsIgnoreCase(FIELD_PHONE) || trimmed.equalsIgnoreCase(FIELD_PHONE_LABEL)) { + stateService.setSessionData(botId, telegramUserId, FIELD_PHONE, value); + updateContactMetadataField(botUser, FIELD_PHONE, value); + } else if (trimmed.equalsIgnoreCase(FIELD_EMAIL) || trimmed.equalsIgnoreCase(FIELD_EMAIL_LABEL)) { + stateService.setSessionData(botId, telegramUserId, FIELD_EMAIL, value); + updateContactMetadataField(botUser, FIELD_EMAIL, value); + } else if (trimmed.toLowerCase().contains(KEYWORD_COOLDOWN)) { + stateService.setSessionData(botId, telegramUserId, trimmed, value); + updateContactCooldown(botUser, trimmed, value); + } else { + stateService.setSessionData(botId, telegramUserId, trimmed, value); + updateContactCustomField(botUser, trimmed, value); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/ActionPlaceholderResolver.java b/backend/src/main/java/com/launchly/bot/engine/action/ActionPlaceholderResolver.java new file mode 100644 index 00000000..252c7cd9 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/ActionPlaceholderResolver.java @@ -0,0 +1,217 @@ +package com.launchly.bot.engine.action; + +import com.launchly.bot.entity.BotUser; +import com.launchly.broadcast.entity.Tag; +import com.launchly.broadcast.repository.BotUserTagRepository; +import com.launchly.broadcast.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ActionPlaceholderResolver { + + private final TagRepository tagRepository; + private final BotUserTagRepository botUserTagRepository; + private final ObjectMapper objectMapper; + + public String resolveValue(String text, Map variables, BotUser botUser) { + if (text == null) { + return ""; + } + String trimmed = text.trim(); + + if (trimmed.contains("+")) { + String[] parts = trimmed.split("\\+"); + List resolvedParts = new ArrayList<>(); + for (String part : parts) { + resolvedParts.add(resolveSingleValue(part.trim(), variables, botUser)); + } + return String.join("_", resolvedParts); + } + + return resolveSingleValue(trimmed, variables, botUser); + } + + @SuppressWarnings("unchecked") + public String resolveSingleValue(String text, Map variables, BotUser botUser) { + if (text == null) { + return ""; + } + String trimmed = text.trim(); + + if (trimmed.equalsIgnoreCase("Full Name") || trimmed.equalsIgnoreCase("full_name") || trimmed.equalsIgnoreCase("Name") || trimmed.equalsIgnoreCase("name")) { + return botUser.getDisplayName(); + } + if (trimmed.equalsIgnoreCase("First Name") || trimmed.equalsIgnoreCase("first_name")) { + return botUser.getFirstName() != null ? botUser.getFirstName() : ""; + } + if (trimmed.equalsIgnoreCase("Last Name") || trimmed.equalsIgnoreCase("last_name")) { + return botUser.getLastName() != null ? botUser.getLastName() : ""; + } + if (trimmed.equalsIgnoreCase("Telegram Username") || trimmed.equalsIgnoreCase("telegram_username") || trimmed.equalsIgnoreCase("username")) { + return botUser.getUsername() != null ? botUser.getUsername() : ""; + } + if (trimmed.equalsIgnoreCase("Phone")) { + return variables != null ? variables.getOrDefault("phone", "") : ""; + } + if (trimmed.equalsIgnoreCase("Email")) { + return variables != null ? variables.getOrDefault("email", "") : ""; + } + if (trimmed.equalsIgnoreCase("Contact Id") || trimmed.equalsIgnoreCase("contact_id")) { + return botUser.getId() != null ? String.valueOf(botUser.getId()) : ""; + } + if (trimmed.equalsIgnoreCase("Subscribed")) { + return variables != null ? variables.getOrDefault("telegram_opt_in", "false") : "false"; + } + if (trimmed.equalsIgnoreCase("Last Reply Type") || trimmed.equalsIgnoreCase("last_reply_type")) { + return variables != null ? variables.getOrDefault("last_reply_type", "text") : "text"; + } + if (trimmed.equalsIgnoreCase("Telegram User ID") || trimmed.equalsIgnoreCase("telegram_user_id")) { + return botUser.getTelegramId() != null ? String.valueOf(botUser.getTelegramId()) : ""; + } + if (trimmed.equalsIgnoreCase("Opted-in for Telegram") || trimmed.equalsIgnoreCase("telegram_opt_in")) { + return variables != null ? variables.getOrDefault("telegram_opt_in", "false") : "false"; + } + + try { + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + Map metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); + Map customFields = (Map) metaMap.get("customFields"); + if (customFields != null) { + for (Map.Entry entry : customFields.entrySet()) { + if (entry.getKey().equalsIgnoreCase(trimmed)) { + return entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + } + } + } + } + } catch (Exception e) { + log.error("Error reading custom fields: {}", e.getMessage()); + } + + try { + List allTags = tagRepository.findByBotId(botUser.getBot().getId()); + for (Tag t : allTags) { + if (t.getName().equalsIgnoreCase(trimmed)) { + boolean hasTag = botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), t.getId()); + return String.valueOf(hasTag); + } + } + } catch (Exception e) { + log.error("Error reading tags: {}", e.getMessage()); + } + + return resolvePlaceholders(text, variables, botUser); + } + + public String replacePlaceholder(String text, String placeholderName, String value) { + if (text == null || placeholderName == null) { + return text; + } + String resolvedVal = value != null ? value : ""; + try { + return text.replaceAll("(?i)" + Pattern.quote("{{" + placeholderName + "}}"), + Matcher.quoteReplacement(resolvedVal)); + } catch (Exception e) { + log.debug("Fallback to string replace for placeholder {}: {}", placeholderName, e.getMessage()); + return text.replace("{{" + placeholderName + "}}", resolvedVal) + .replace("{{" + placeholderName.toLowerCase() + "}}", resolvedVal); + } + } + + @SuppressWarnings("unchecked") + public String resolvePlaceholders(String text, Map variables, BotUser botUser) { + if (text == null) { + return ""; + } + String result = text; + + result = replacePlaceholder(result, "First Name", botUser.getFirstName()); + result = replacePlaceholder(result, "first_name", botUser.getFirstName()); + + result = replacePlaceholder(result, "Last Name", botUser.getLastName()); + result = replacePlaceholder(result, "last_name", botUser.getLastName()); + + result = replacePlaceholder(result, "Telegram Username", botUser.getUsername()); + result = replacePlaceholder(result, "telegram_username", botUser.getUsername()); + result = replacePlaceholder(result, "username", botUser.getUsername()); + + result = replacePlaceholder(result, "Telegram User ID", botUser.getTelegramId() != null ? String.valueOf(botUser.getTelegramId()) : null); + result = replacePlaceholder(result, "telegram_user_id", botUser.getTelegramId() != null ? String.valueOf(botUser.getTelegramId()) : null); + + result = replacePlaceholder(result, "Contact ID", botUser.getId() != null ? String.valueOf(botUser.getId()) : null); + result = replacePlaceholder(result, "Contact Id", botUser.getId() != null ? String.valueOf(botUser.getId()) : null); + result = replacePlaceholder(result, "contact_id", botUser.getId() != null ? String.valueOf(botUser.getId()) : null); + + if (variables != null) { + for (Map.Entry entry : variables.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + result = replacePlaceholder(result, entry.getKey(), entry.getValue()); + } + } + + result = replacePlaceholder(result, "Phone", variables.get("phone")); + result = replacePlaceholder(result, "phone", variables.get("phone")); + + result = replacePlaceholder(result, "Email", variables.get("email")); + result = replacePlaceholder(result, "email", variables.get("email")); + + result = replacePlaceholder(result, "Subscribed", variables.get("telegram_opt_in")); + result = replacePlaceholder(result, "telegram_opt_in", variables.get("telegram_opt_in")); + result = replacePlaceholder(result, "Opted-in for Telegram", variables.get("telegram_opt_in")); + + result = replacePlaceholder(result, "Last Reply Type", variables.get("last_reply_type")); + result = replacePlaceholder(result, "last_reply_type", variables.get("last_reply_type")); + } + + try { + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + Map metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); + Map customFields = (Map) metaMap.get("customFields"); + if (customFields != null) { + for (Map.Entry entry : customFields.entrySet()) { + String valStr = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + result = replacePlaceholder(result, entry.getKey(), valStr); + } + } + Map chatCustomFields = (Map) metaMap.get("chatCustomFields"); + if (chatCustomFields != null) { + for (Object groupVal : chatCustomFields.values()) { + if (groupVal instanceof Map scopeMap) { + for (Map.Entry entry : scopeMap.entrySet()) { + String valStr = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + result = replacePlaceholder(result, String.valueOf(entry.getKey()), valStr); + } + } + } + } + } + } catch (Exception e) { + log.error("Error replacing custom field placeholders for user {}: {}", botUser.getId(), e.getMessage()); + } + + try { + List allTags = tagRepository.findByBotId(botUser.getBot().getId()); + for (Tag t : allTags) { + boolean hasTag = botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), t.getId()); + String val = String.valueOf(hasTag); + result = replacePlaceholder(result, "tag:" + t.getName(), val); + result = replacePlaceholder(result, "tag." + t.getName(), val); + result = replacePlaceholder(result, t.getName(), val); + } + } catch (Exception e) { + log.error("Error replacing tag placeholders for user {}: {}", botUser.getId(), e.getMessage()); + } + + return result; + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/BotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/BotActionHandler.java new file mode 100644 index 00000000..ddb87f97 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/BotActionHandler.java @@ -0,0 +1,10 @@ +package com.launchly.bot.engine.action; + +import com.launchly.bot.entity.BotUser; +import java.util.Map; +import java.util.Set; + +public interface BotActionHandler { + Set getSupportedTypes(); + void execute(String type, Map action, BotUser botUser, Map sessionData); +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/handler/AssigneeNotificationBotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/handler/AssigneeNotificationBotActionHandler.java new file mode 100644 index 00000000..20e8b1cf --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/handler/AssigneeNotificationBotActionHandler.java @@ -0,0 +1,35 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.auth.entity.User; +import com.launchly.bot.engine.action.BotActionHandler; +import com.launchly.bot.entity.BotUser; +import com.launchly.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AssigneeNotificationBotActionHandler implements BotActionHandler { + + private final NotificationService notificationService; + + @Override + public Set getSupportedTypes() { + return Set.of("NOTIFY_ASSIGNEES", "NOTIFY_ASSIGNEE"); + } + + @Override + public void execute(String type, Map action, BotUser botUser, Map sessionData) { + User botOwner = botUser.getBot().getUser(); + if (botOwner != null) { + notificationService.sendAssignmentNotification(botOwner.getId(), botUser.getId()); + } else { + log.warn("Cannot send assignee notification: bot owner is null"); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/handler/GoogleSheetsBotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/handler/GoogleSheetsBotActionHandler.java new file mode 100644 index 00000000..0d7b0d9f --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/handler/GoogleSheetsBotActionHandler.java @@ -0,0 +1,256 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.bot.engine.action.ActionContactManager; +import com.launchly.bot.engine.action.ActionPlaceholderResolver; +import com.launchly.bot.engine.action.BotActionHandler; +import com.launchly.bot.entity.BotUser; +import com.launchly.integration.entity.Integration; +import com.launchly.integration.entity.IntegrationType; +import com.launchly.integration.repository.IntegrationRepository; +import com.launchly.integration.service.GoogleSheetsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GoogleSheetsBotActionHandler implements BotActionHandler { + + private final IntegrationRepository integrationRepository; + private final GoogleSheetsService googleSheetsService; + private final ActionPlaceholderResolver placeholderResolver; + private final ActionContactManager contactManager; + + @Override + public Set getSupportedTypes() { + return Set.of("GS_INSERT_ROW", "GS_GET_ROW", "GS_UPDATE_ROW"); + } + + @Override + @SuppressWarnings("unchecked") + public void execute(String type, Map action, BotUser botUser, Map sessionData) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + + Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS).orElse(null); + if (integration == null) { + log.warn("Skipping Google Sheets {}: no GOOGLE_SHEETS integration configured for bot {}", type, botId); + return; + } + + String spreadsheetId = (String) action.get("spreadsheetId"); + String sheetName = (String) action.get("sheetName"); + List> mappings = (List>) action.get("columnMappings"); + + if (spreadsheetId == null || spreadsheetId.isEmpty()) { + return; + } + + String activeSpreadsheetId = resolveSpreadsheetId(botId, spreadsheetId); + String activeSheetName = sheetName != null && !sheetName.trim().isEmpty() ? sheetName.trim() : "Sheet1"; + + switch (type) { + case "GS_INSERT_ROW": + handleInsertRow(integration, botId, telegramUserId, activeSpreadsheetId, activeSheetName, mappings, sessionData, botUser); + break; + case "GS_GET_ROW": + handleGetRow(botId, telegramUserId, activeSpreadsheetId, activeSheetName, action, mappings, sessionData, botUser); + break; + case "GS_UPDATE_ROW": + handleUpdateRow(botId, telegramUserId, activeSpreadsheetId, activeSheetName, action, mappings, sessionData, botUser); + break; + default: + log.warn("Unsupported Google Sheets action: {}", type); + } + } + + private void handleInsertRow(Integration integration, Long botId, Long telegramUserId, + String activeSpreadsheetId, String activeSheetName, + List> mappings, Map sessionData, BotUser botUser) { + List headers = googleSheetsService.getHeaders(botId, activeSpreadsheetId, activeSheetName); + List values = new ArrayList<>(); + boolean hasMappings = mappings != null && !mappings.isEmpty(); + if (headers != null && !headers.isEmpty()) { + for (int headerIndex = 0; headerIndex < headers.size(); headerIndex++) { + String header = headers.get(headerIndex); + String resolvedVal = ""; + if (hasMappings) { + for (Map m : mappings) { + if (header.equals(m.get("column"))) { + resolvedVal = placeholderResolver.resolveValue(m.get("value"), sessionData, botUser); + break; + } + } + } else if (headerIndex == 0) { + resolvedVal = placeholderResolver.resolveValue("{{username}}", sessionData, botUser); + } + values.add(resolvedVal); + } + } else { + if (hasMappings) { + values = mappings.stream() + .map(m -> placeholderResolver.resolveValue(m.get("value"), sessionData, botUser)) + .collect(Collectors.toList()); + } else { + values.add(placeholderResolver.resolveValue("{{username}}", sessionData, botUser)); + } + } + googleSheetsService.appendRow(integration, activeSpreadsheetId, activeSheetName, values); + log.info("Inserted row into Google Sheets spreadsheet={} sheet={} for bot user {}", activeSpreadsheetId, activeSheetName, telegramUserId); + } + + private void handleGetRow(Long botId, Long telegramUserId, String activeSpreadsheetId, String activeSheetName, + Map action, List> mappings, + Map sessionData, BotUser botUser) { + String lookupColumn = (String) action.get("lookupColumn"); + String lookupValue = (String) action.get("lookupValue"); + if (lookupColumn == null || lookupColumn.isEmpty()) { + return; + } + String resolvedLookupVal = placeholderResolver.resolveValue(lookupValue, sessionData, botUser).trim(); + + List> sheetValues = googleSheetsService.getSheetValues(botId, activeSpreadsheetId, activeSheetName); + if (sheetValues != null && !sheetValues.isEmpty()) { + List headers = sheetValues.get(0); + int lookupColIdx = -1; + for (int i = 0; i < headers.size(); i++) { + if (lookupColumn.equalsIgnoreCase(String.valueOf(headers.get(i)).trim())) { + lookupColIdx = i; + break; + } + } + + if (lookupColIdx != -1) { + for (int rowIndex = 1; rowIndex < sheetValues.size(); rowIndex++) { + List row = sheetValues.get(rowIndex); + if (row.size() > lookupColIdx) { + String cellVal = String.valueOf(row.get(lookupColIdx)).trim(); + if (cellVal.equalsIgnoreCase(resolvedLookupVal)) { + log.info("Found matching row at index {} in Google Sheets for bot user {}", rowIndex, telegramUserId); + if (mappings != null) { + for (Map m : mappings) { + String targetGoogleCol = m.get("column"); + String targetLaunchlyField = m.get("value"); + if (targetGoogleCol != null && targetLaunchlyField != null) { + int targetColIdx = -1; + for (int hIdx = 0; hIdx < headers.size(); hIdx++) { + if (targetGoogleCol.equalsIgnoreCase(String.valueOf(headers.get(hIdx)).trim())) { + targetColIdx = hIdx; + break; + } + } + if (targetColIdx != -1 && row.size() > targetColIdx) { + String targetValue = String.valueOf(row.get(targetColIdx)).trim(); + contactManager.setContactField(botUser, botId, telegramUserId, targetLaunchlyField, targetValue, sessionData); + } + } + } + } + break; + } + } + } + } else { + log.warn("Lookup column '{}' not found in spreadsheet {} headers", lookupColumn, activeSpreadsheetId); + } + } + } + + private void handleUpdateRow(Long botId, Long telegramUserId, String activeSpreadsheetId, String activeSheetName, + Map action, List> mappings, + Map sessionData, BotUser botUser) { + String lookupColumn = (String) action.get("lookupColumn"); + String lookupValue = (String) action.get("lookupValue"); + if (lookupColumn == null || lookupColumn.isEmpty()) { + return; + } + String resolvedLookupVal = placeholderResolver.resolveValue(lookupValue, sessionData, botUser).trim(); + + List> sheetValues = googleSheetsService.getSheetValues(botId, activeSpreadsheetId, activeSheetName); + if (sheetValues != null && !sheetValues.isEmpty()) { + List headers = sheetValues.get(0); + int lookupColIdx = -1; + for (int i = 0; i < headers.size(); i++) { + if (lookupColumn.equalsIgnoreCase(String.valueOf(headers.get(i)).trim())) { + lookupColIdx = i; + break; + } + } + + if (lookupColIdx != -1) { + for (int rowIndex = 1; rowIndex < sheetValues.size(); rowIndex++) { + List row = sheetValues.get(rowIndex); + if (row.size() > lookupColIdx) { + String cellVal = String.valueOf(row.get(lookupColIdx)).trim(); + if (cellVal.equalsIgnoreCase(resolvedLookupVal)) { + log.info("Updating matching row at index {} in Google Sheets for bot user {}", rowIndex, telegramUserId); + if (mappings != null) { + for (Map m : mappings) { + String targetGoogleCol = m.get("column"); + String targetValueExpr = m.get("value"); + if (targetGoogleCol != null && targetValueExpr != null && !targetValueExpr.trim().isEmpty()) { + int targetColIdx = -1; + for (int hIdx = 0; hIdx < headers.size(); hIdx++) { + if (targetGoogleCol.equalsIgnoreCase(String.valueOf(headers.get(hIdx)).trim())) { + targetColIdx = hIdx; + break; + } + } + if (targetColIdx != -1) { + String resolvedVal = placeholderResolver.resolveValue(targetValueExpr, sessionData, botUser); + String colLetter = getColumnLetter(targetColIdx); + String cellRef = colLetter + (rowIndex + 1); + googleSheetsService.updateCell(botId, activeSpreadsheetId, activeSheetName, cellRef, resolvedVal); + } + } + } + } + break; + } + } + } + } else { + log.warn("Lookup column '{}' not found in spreadsheet {} headers", lookupColumn, activeSpreadsheetId); + } + } + } + + private String resolveSpreadsheetId(Long botId, String spreadsheetIdOrName) { + String value = spreadsheetIdOrName != null ? spreadsheetIdOrName.trim() : ""; + if (value.isEmpty()) { + return value; + } + + try { + List> spreadsheets = googleSheetsService.getSpreadsheets(botId); + for (Map spreadsheet : spreadsheets) { + String id = spreadsheet.get("id"); + String name = spreadsheet.get("name"); + if (value.equals(id) || (name != null && value.equalsIgnoreCase(name.trim()))) { + return id; + } + } + } catch (Exception e) { + log.warn("Failed to resolve spreadsheet '{}' by name for bot {}: {}", value, botId, e.getMessage()); + } + + return value; + } + + private String getColumnLetter(int colIndex) { + StringBuilder sb = new StringBuilder(); + int temp = colIndex; + while (temp >= 0) { + sb.insert(0, (char) ('A' + (temp % 26))); + temp = (temp / 26) - 1; + } + return sb.toString(); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/handler/MailchimpBotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/handler/MailchimpBotActionHandler.java new file mode 100644 index 00000000..38479543 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/handler/MailchimpBotActionHandler.java @@ -0,0 +1,69 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.bot.engine.action.ActionPlaceholderResolver; +import com.launchly.bot.engine.action.BotActionHandler; +import com.launchly.bot.entity.BotUser; +import com.launchly.integration.entity.Integration; +import com.launchly.integration.entity.IntegrationType; +import com.launchly.integration.repository.IntegrationRepository; +import com.launchly.integration.service.MailchimpService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MailchimpBotActionHandler implements BotActionHandler { + + private final IntegrationRepository integrationRepository; + private final MailchimpService mailchimpService; + private final ActionPlaceholderResolver placeholderResolver; + + @Override + public Set getSupportedTypes() { + return Set.of("MAILCHIMP_SUBSCRIBE"); + } + + @Override + public void execute(String type, Map action, BotUser botUser, Map sessionData) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + + Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.MAILCHIMP).orElse(null); + if (integration != null && integration.isActive()) { + String email = placeholderResolver.resolveValue((String) action.get("email"), sessionData, botUser); + if (email == null || email.trim().isEmpty()) { + email = sessionData != null ? sessionData.getOrDefault("email", "") : ""; + } + String firstName = placeholderResolver.resolveValue((String) action.get("firstName"), sessionData, botUser); + if (firstName == null || firstName.trim().isEmpty()) { + firstName = botUser.getFirstName() != null ? botUser.getFirstName() : ""; + } + String lastName = placeholderResolver.resolveValue((String) action.get("lastName"), sessionData, botUser); + if (lastName == null || lastName.trim().isEmpty()) { + lastName = botUser.getLastName() != null ? botUser.getLastName() : ""; + } + String phone = placeholderResolver.resolveValue((String) action.get("phone"), sessionData, botUser); + if (phone == null || phone.trim().isEmpty()) { + phone = sessionData != null ? sessionData.getOrDefault("phone", "") : ""; + } + Object tagsObj = action.get("tags"); + List tags = null; + if (tagsObj instanceof List) { + tags = ((List) tagsObj).stream().map(String::valueOf).collect(Collectors.toList()); + } + if (email != null && !email.trim().isEmpty()) { + mailchimpService.addOrUpdateSubscriber(integration, email.trim(), firstName.trim(), lastName.trim(), phone.trim(), tags); + log.info("Triggered Mailchimp subscribe for user {} email {}", telegramUserId, email); + } + } else { + log.warn("Skipping Mailchimp subscribe: no active MAILCHIMP integration for bot {}", botId); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/handler/NotifyContactBotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/handler/NotifyContactBotActionHandler.java new file mode 100644 index 00000000..df9f780e --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/handler/NotifyContactBotActionHandler.java @@ -0,0 +1,132 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.bot.engine.action.BotActionHandler; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.service.TelegramSendService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotifyContactBotActionHandler implements BotActionHandler { + + private static final String ACTION_NOTIFY_CONTACT = "NOTIFY_CONTACT"; + private static final String ACTION_SEND_MESSAGE_TO_CONTACT = "SEND_MESSAGE_TO_CONTACT"; + + private static final String KEY_TARGET_USER_ID = "targetUserId"; + private static final String KEY_TARGET_TELEGRAM_ID = "targetTelegramId"; + private static final String KEY_TEXT = "text"; + private static final String KEY_MESSAGE_TEXT = "messageText"; + private static final String KEY_PHOTO_URL = "photoUrl"; + + private static final String VAR_FOUND_USER_TELEGRAM_ID_PATTERN = "{found_user.telegram_id}"; + private static final String VAR_FOUND_USER_TELEGRAM_ID = "found_user.telegram_id"; + private static final String VAR_FOUND_TELEGRAM_ID = "found_telegram_id"; + private static final String VAR_TARGET_TELEGRAM_ID = "target_telegram_id"; + private static final String VAR_PARTNER_TELEGRAM_ID = "partner_telegram_id"; + + private static final String VAR_TELEGRAM_ID = "telegram_id"; + private static final String VAR_FIRST_NAME = "first_name"; + private static final String VAR_LAST_NAME = "last_name"; + private static final String VAR_USERNAME = "username"; + + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{+([^{}]+)\\}+"); + + private final TelegramSendService telegramSendService; + + @Override + public Set getSupportedTypes() { + return Set.of(ACTION_NOTIFY_CONTACT, ACTION_SEND_MESSAGE_TO_CONTACT); + } + + @Override + public void execute(String type, Map action, BotUser botUser, Map sessionData) { + Long botId = botUser.getBot().getId(); + + String rawTargetId = (String) action.getOrDefault(KEY_TARGET_USER_ID, action.getOrDefault(KEY_TARGET_TELEGRAM_ID, "")); + String rawText = (String) action.getOrDefault(KEY_TEXT, action.getOrDefault(KEY_MESSAGE_TEXT, "")); + String rawPhotoUrl = (String) action.getOrDefault(KEY_PHOTO_URL, ""); + + Long targetTelegramId = resolveTargetTelegramId(rawTargetId, sessionData, botUser); + if (targetTelegramId == null) { + log.warn("Cannot notify contact: target Telegram ID could not be resolved from '{}'", rawTargetId); + return; + } + + String resolvedText = resolvePlaceholders(rawText, sessionData, botUser); + String resolvedPhotoUrl = resolvePlaceholders(rawPhotoUrl, sessionData, botUser); + + try { + if (resolvedPhotoUrl != null && !resolvedPhotoUrl.trim().isEmpty()) { + telegramSendService.sendPhoto(botId, targetTelegramId, resolvedPhotoUrl.trim(), resolvedText); + } else if (resolvedText != null && !resolvedText.trim().isEmpty()) { + telegramSendService.sendMessage(botId, targetTelegramId, resolvedText); + } + log.info("Sent cross-contact notification from bot {} to target user {}", botId, targetTelegramId); + } catch (Exception e) { + log.error("Failed to send cross-contact notification to {}: {}", targetTelegramId, e.getMessage(), e); + } + } + + private Long resolveTargetTelegramId(String raw, Map sessionData, BotUser botUser) { + if (raw == null || raw.trim().isEmpty() || VAR_FOUND_USER_TELEGRAM_ID_PATTERN.equalsIgnoreCase(raw.trim())) { + if (sessionData != null) { + String candidateId = sessionData.get(VAR_FOUND_USER_TELEGRAM_ID); + if (candidateId == null) candidateId = sessionData.get(VAR_FOUND_TELEGRAM_ID); + if (candidateId == null) candidateId = sessionData.get(VAR_TARGET_TELEGRAM_ID); + if (candidateId == null) candidateId = sessionData.get(VAR_PARTNER_TELEGRAM_ID); + if (candidateId != null && !candidateId.trim().isEmpty()) { + try { + return Long.parseLong(candidateId.trim()); + } catch (NumberFormatException ignored) {} + } + } + if (raw == null || raw.trim().isEmpty()) return null; + } + String val = raw.trim(); + if (val.startsWith("{") && val.endsWith("}")) { + String key = val.substring(1, val.length() - 1).trim(); + if (sessionData != null && sessionData.containsKey(key)) { + val = sessionData.get(key); + } else if (VAR_TELEGRAM_ID.equalsIgnoreCase(key)) { + return botUser.getTelegramId(); + } + } + try { + return Long.parseLong(val.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private String resolvePlaceholders(String text, Map sessionData, BotUser botUser) { + if (text == null) return ""; + Matcher matcher = PLACEHOLDER_PATTERN.matcher(text); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String key = matcher.group(1).trim(); + String replacement = ""; + if (sessionData != null && sessionData.containsKey(key)) { + replacement = sessionData.get(key); + } else if (VAR_FIRST_NAME.equalsIgnoreCase(key)) { + replacement = botUser.getFirstName() != null ? botUser.getFirstName() : ""; + } else if (VAR_LAST_NAME.equalsIgnoreCase(key)) { + replacement = botUser.getLastName() != null ? botUser.getLastName() : ""; + } else if (VAR_USERNAME.equalsIgnoreCase(key)) { + replacement = botUser.getUsername() != null ? botUser.getUsername() : ""; + } else if (VAR_TELEGRAM_ID.equalsIgnoreCase(key)) { + replacement = botUser.getTelegramId() != null ? botUser.getTelegramId().toString() : ""; + } + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/handler/TagBotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/handler/TagBotActionHandler.java new file mode 100644 index 00000000..5b47122f --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/handler/TagBotActionHandler.java @@ -0,0 +1,66 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.bot.engine.action.BotActionHandler; +import com.launchly.bot.entity.BotUser; +import com.launchly.broadcast.entity.BotUserTag; +import com.launchly.broadcast.entity.Tag; +import com.launchly.broadcast.repository.BotUserTagRepository; +import com.launchly.broadcast.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TagBotActionHandler implements BotActionHandler { + + private final TagRepository tagRepository; + private final BotUserTagRepository botUserTagRepository; + + @Override + public Set getSupportedTypes() { + return Set.of("ADD_TAG", "REMOVE_TAG"); + } + + @Override + public void execute(String type, Map action, BotUser botUser, Map sessionData) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + String tagName = (String) action.get("tagName"); + Object tagIdObj = action.get("tagId"); + + if ("ADD_TAG".equals(type)) { + if (tagIdObj != null && !String.valueOf(tagIdObj).isEmpty()) { + Long tagId = Long.parseLong(String.valueOf(tagIdObj)); + tagRepository.findById(tagId).ifPresent(tag -> { + if (!botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), tag.getId())) { + botUserTagRepository.save(BotUserTag.builder().botUser(botUser).tag(tag).build()); + log.info("Added tag ID {} to user {}", tag.getId(), telegramUserId); + } + }); + } else if (tagName != null && !tagName.trim().isEmpty()) { + Tag tag = tagRepository.findByBotIdAndName(botId, tagName.trim()) + .orElseGet(() -> tagRepository.save(Tag.builder().name(tagName.trim()).bot(botUser.getBot()).build())); + if (!botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), tag.getId())) { + botUserTagRepository.save(BotUserTag.builder().botUser(botUser).tag(tag).build()); + log.info("Created and added tag '{}' to user {}", tagName, telegramUserId); + } + } + } else if ("REMOVE_TAG".equals(type)) { + if (tagIdObj != null && !String.valueOf(tagIdObj).isEmpty()) { + Long tagId = Long.parseLong(String.valueOf(tagIdObj)); + botUserTagRepository.deleteByBotUserIdAndTagId(botUser.getId(), tagId); + log.info("Removed tag ID {} from user {}", tagId, telegramUserId); + } else if (tagName != null && !tagName.trim().isEmpty()) { + tagRepository.findByBotIdAndName(botId, tagName.trim()).ifPresent(tag -> { + botUserTagRepository.deleteByBotUserIdAndTagId(botUser.getId(), tag.getId()); + log.info("Removed tag '{}' from user {}", tagName, telegramUserId); + }); + } + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/handler/TelegramSubscriptionBotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/handler/TelegramSubscriptionBotActionHandler.java new file mode 100644 index 00000000..6a8fd8bf --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/handler/TelegramSubscriptionBotActionHandler.java @@ -0,0 +1,42 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.bot.engine.action.ActionContactManager; +import com.launchly.bot.engine.action.BotActionHandler; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.service.BotDialogStateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TelegramSubscriptionBotActionHandler implements BotActionHandler { + + private final BotDialogStateService stateService; + private final ActionContactManager contactManager; + + @Override + public Set getSupportedTypes() { + return Set.of("TELEGRAM_SUBSCRIBE", "TELEGRAM_UNSUBSCRIBE"); + } + + @Override + public void execute(String type, Map action, BotUser botUser, Map sessionData) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + + if ("TELEGRAM_SUBSCRIBE".equals(type)) { + stateService.setSessionData(botId, telegramUserId, "telegram_opt_in", "true"); + contactManager.updateContactMetadataField(botUser, "telegram_opt_in", true); + log.info("Subscribed user {} to Telegram updates", telegramUserId); + } else if ("TELEGRAM_UNSUBSCRIBE".equals(type)) { + stateService.setSessionData(botId, telegramUserId, "telegram_opt_in", "false"); + contactManager.updateContactMetadataField(botUser, "telegram_opt_in", false); + log.info("Unsubscribed user {} from Telegram updates", telegramUserId); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/action/handler/UserFieldBotActionHandler.java b/backend/src/main/java/com/launchly/bot/engine/action/handler/UserFieldBotActionHandler.java new file mode 100644 index 00000000..558b50a0 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/action/handler/UserFieldBotActionHandler.java @@ -0,0 +1,52 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.bot.engine.action.ActionContactManager; +import com.launchly.bot.engine.action.ActionPlaceholderResolver; +import com.launchly.bot.engine.action.BotActionHandler; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.service.BotDialogStateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserFieldBotActionHandler implements BotActionHandler { + + private final BotDialogStateService stateService; + private final ActionPlaceholderResolver placeholderResolver; + private final ActionContactManager contactManager; + + @Override + public Set getSupportedTypes() { + return Set.of("SET_USER_FIELD", "CLEAR_USER_FIELD"); + } + + @Override + public void execute(String type, Map action, BotUser botUser, Map sessionData) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + String fieldName = (String) action.get("fieldName"); + + if (fieldName == null || fieldName.trim().isEmpty()) { + return; + } + String cleanFieldName = fieldName.trim(); + + if ("SET_USER_FIELD".equals(type)) { + String fieldValue = (String) action.get("fieldValue"); + String resolvedValue = placeholderResolver.resolveValue(fieldValue, sessionData, botUser); + stateService.setSessionData(botId, telegramUserId, cleanFieldName, resolvedValue); + contactManager.updateContactCustomField(botUser, cleanFieldName, resolvedValue); + log.info("Set field '{}' = '{}' for user {}", cleanFieldName, resolvedValue, telegramUserId); + } else if ("CLEAR_USER_FIELD".equals(type)) { + stateService.setSessionData(botId, telegramUserId, cleanFieldName, ""); + contactManager.updateContactCustomField(botUser, cleanFieldName, null); + log.info("Cleared field '{}' for user {}", cleanFieldName, telegramUserId); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/cache/FlowSchemaCache.java b/backend/src/main/java/com/launchly/bot/engine/cache/FlowSchemaCache.java new file mode 100644 index 00000000..be0a0c04 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/cache/FlowSchemaCache.java @@ -0,0 +1,75 @@ +package com.launchly.bot.engine.cache; + +import com.launchly.bot.entity.FlowSchema; +import com.launchly.bot.repository.FlowSchemaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FlowSchemaCache { + + private static final String SCHEMA_KEY = "launchly:bot:schema:%d"; + private static final Duration SCHEMA_TTL = Duration.ofMinutes(30); + + private final StringRedisTemplate redisTemplate; + private final FlowSchemaRepository flowSchemaRepository; + private final ObjectMapper objectMapper; + + private record CachedSchema(Long id, int version, String nodes, String edges) {} + + public FlowSchema getSchema(Long botId) { + String key = String.format(SCHEMA_KEY, botId); + String cached = redisTemplate.opsForValue().get(key); + + if (cached != null) { + try { + CachedSchema cachedSchema = objectMapper.readValue(cached, CachedSchema.class); + FlowSchema schema = new FlowSchema(); + schema.setId(cachedSchema.id()); + schema.setVersion(cachedSchema.version()); + schema.setNodes(cachedSchema.nodes()); + schema.setEdges(cachedSchema.edges()); + return schema; + } catch (Exception e) { + log.error("Failed to deserialize cached schema for bot {}: {}", botId, e.getMessage(), e); + } + } + + Optional schemaOpt = flowSchemaRepository.findByBotId(botId); + if (schemaOpt.isEmpty()) { + return null; + } + + FlowSchema schema = schemaOpt.get(); + String pubNodes = schema.getEffectivePublishedNodes(); + String pubEdges = schema.getEffectivePublishedEdges(); + try { + CachedSchema cachedSchema = new CachedSchema(schema.getId(), schema.getVersion(), pubNodes, pubEdges); + redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(cachedSchema), SCHEMA_TTL); + } catch (Exception e) { + log.error("Failed to serialize schema for bot {}: {}", botId, e.getMessage(), e); + } + + FlowSchema effectiveSchema = new FlowSchema(); + effectiveSchema.setId(schema.getId()); + effectiveSchema.setVersion(schema.getVersion()); + effectiveSchema.setNodes(pubNodes); + effectiveSchema.setEdges(pubEdges); + effectiveSchema.setPublishedNodes(pubNodes); + effectiveSchema.setPublishedEdges(pubEdges); + effectiveSchema.setBot(schema.getBot()); + return effectiveSchema; + } + + public void evictSchema(Long botId) { + String key = String.format(SCHEMA_KEY, botId); + redisTemplate.delete(key); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/ActionNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/ActionNodeExecutor.java index dd2ef97e..84589031 100644 --- a/backend/src/main/java/com/launchly/bot/engine/executor/ActionNodeExecutor.java +++ b/backend/src/main/java/com/launchly/bot/engine/executor/ActionNodeExecutor.java @@ -1,49 +1,37 @@ package com.launchly.bot.engine.executor; +import com.launchly.bot.engine.action.BotActionHandler; import com.launchly.bot.engine.model.FlowEdge; import com.launchly.bot.engine.model.FlowNode; import com.launchly.bot.entity.BotUser; -import com.launchly.auth.entity.User; import com.launchly.bot.entity.NodeType; import com.launchly.bot.service.BotDialogStateService; -import com.launchly.bot.repository.BotUserRepository; -import com.launchly.broadcast.entity.BotUserTag; -import com.launchly.broadcast.entity.Tag; -import com.launchly.broadcast.repository.BotUserTagRepository; -import com.launchly.broadcast.repository.TagRepository; -import com.launchly.integration.entity.Integration; -import com.launchly.integration.entity.IntegrationType; -import com.launchly.integration.repository.IntegrationRepository; -import com.launchly.integration.service.GoogleSheetsService; -import com.launchly.integration.service.MailchimpService; -import com.launchly.notification.service.NotificationService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.generics.TelegramClient; -import java.util.ArrayList; + import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import tools.jackson.databind.ObjectMapper; @Slf4j @Component -@RequiredArgsConstructor public class ActionNodeExecutor implements NodeExecutor { private final BotDialogStateService stateService; - private final BotUserRepository botUserRepository; - private final TagRepository tagRepository; - private final BotUserTagRepository botUserTagRepository; - private final IntegrationRepository integrationRepository; - private final GoogleSheetsService googleSheetsService; - private final MailchimpService mailchimpService; - private final NotificationService notificationService; + private final Map handlerMap = new HashMap<>(); + + public ActionNodeExecutor(BotDialogStateService stateService, List handlers) { + this.stateService = stateService; + if (handlers != null) { + for (BotActionHandler handler : handlers) { + for (String type : handler.getSupportedTypes()) { + handlerMap.put(type, handler); + } + } + } + } @Override public NodeType getType() { @@ -66,312 +54,16 @@ public String execute(FlowNode node, List edges, BotUser botUser, for (Map action : actions) { String type = (String) action.get("type"); - if (type == null) continue; + if (type == null) { + continue; + } try { - switch (type) { - case "ADD_TAG": { - String tagName = (String) action.get("tagName"); - Object tagIdObj = action.get("tagId"); - if (tagIdObj != null && !String.valueOf(tagIdObj).isEmpty()) { - Long tagId = Long.parseLong(String.valueOf(tagIdObj)); - tagRepository.findById(tagId).ifPresent(tag -> { - if (!botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), tag.getId())) { - botUserTagRepository.save(BotUserTag.builder().botUser(botUser).tag(tag).build()); - log.info("Added tag ID {} to user {}", tag.getId(), telegramUserId); - } - }); - } else if (tagName != null && !tagName.trim().isEmpty()) { - Tag tag = tagRepository.findByBotIdAndName(botId, tagName.trim()) - .orElseGet(() -> tagRepository.save(Tag.builder().name(tagName.trim()).bot(botUser.getBot()).build())); - if (!botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), tag.getId())) { - botUserTagRepository.save(BotUserTag.builder().botUser(botUser).tag(tag).build()); - log.info("Created and added tag '{}' to user {}", tagName, telegramUserId); - } - } - break; - } - - case "REMOVE_TAG": { - String tagName = (String) action.get("tagName"); - Object tagIdObj = action.get("tagId"); - if (tagIdObj != null && !String.valueOf(tagIdObj).isEmpty()) { - Long tagId = Long.parseLong(String.valueOf(tagIdObj)); - botUserTagRepository.deleteByBotUserIdAndTagId(botUser.getId(), tagId); - log.info("Removed tag ID {} from user {}", tagId, telegramUserId); - } else if (tagName != null && !tagName.trim().isEmpty()) { - tagRepository.findByBotIdAndName(botId, tagName.trim()).ifPresent(tag -> { - botUserTagRepository.deleteByBotUserIdAndTagId(botUser.getId(), tag.getId()); - log.info("Removed tag '{}' from user {}", tagName, telegramUserId); - }); - } - break; - } - - case "SET_USER_FIELD": { - String fieldName = (String) action.get("fieldName"); - String fieldValue = (String) action.get("fieldValue"); - if (fieldName != null && !fieldName.trim().isEmpty()) { - String resolvedValue = resolveValue(fieldValue, sessionData, botUser); - stateService.setSessionData(botId, telegramUserId, fieldName.trim(), resolvedValue); - updateContactCustomField(botUser, fieldName.trim(), resolvedValue); - log.info("Set field '{}' = '{}' for user {}", fieldName, resolvedValue, telegramUserId); - } - break; - } - - case "CLEAR_USER_FIELD": { - String fieldName = (String) action.get("fieldName"); - if (fieldName != null && !fieldName.trim().isEmpty()) { - stateService.setSessionData(botId, telegramUserId, fieldName.trim(), ""); - updateContactCustomField(botUser, fieldName.trim(), null); - log.info("Cleared field '{}' for user {}", fieldName, telegramUserId); - } - break; - } - - case "TELEGRAM_SUBSCRIBE": { - stateService.setSessionData(botId, telegramUserId, "telegram_opt_in", "true"); - updateContactMetadataField(botUser, "telegram_opt_in", true); - log.info("Subscribed user {} to Telegram updates", telegramUserId); - break; - } - - case "TELEGRAM_UNSUBSCRIBE": { - stateService.setSessionData(botId, telegramUserId, "telegram_opt_in", "false"); - updateContactMetadataField(botUser, "telegram_opt_in", false); - log.info("Unsubscribed user {} from Telegram updates", telegramUserId); - break; - } - - case "GS_INSERT_ROW": { - Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS).orElse(null); - if (integration == null) { - log.warn("Skipping Google Sheets insert row: no GOOGLE_SHEETS integration configured for bot {}", botId); - break; - } - String spreadsheetId = (String) action.get("spreadsheetId"); - String sheetName = (String) action.get("sheetName"); - List> mappings = (List>) action.get("columnMappings"); - - if (spreadsheetId != null && !spreadsheetId.isEmpty()) { - String activeSpreadsheetId = resolveSpreadsheetId(botId, spreadsheetId); - String activeSheetName = sheetName != null && !sheetName.trim().isEmpty() ? sheetName.trim() : "Sheet1"; - List headers = googleSheetsService.getHeaders(botId, activeSpreadsheetId, activeSheetName); - List values = new ArrayList<>(); - boolean hasMappings = mappings != null && !mappings.isEmpty(); - if (headers != null && !headers.isEmpty()) { - for (int headerIndex = 0; headerIndex < headers.size(); headerIndex++) { - String header = headers.get(headerIndex); - String resolvedVal = ""; - if (hasMappings) { - for (Map m : mappings) { - if (header.equals(m.get("column"))) { - resolvedVal = resolveValue(m.get("value"), sessionData, botUser); - break; - } - } - } else if (headerIndex == 0) { - resolvedVal = resolveValue("{{username}}", sessionData, botUser); - } - values.add(resolvedVal); - } - } else { - if (hasMappings) { - values = mappings.stream() - .map(m -> resolveValue(m.get("value"), sessionData, botUser)) - .collect(Collectors.toList()); - } else { - values.add(resolveValue("{{username}}", sessionData, botUser)); - } - } - googleSheetsService.appendRow(integration, activeSpreadsheetId, activeSheetName, values); - log.info("Inserted row into Google Sheets spreadsheet={} sheet={} for bot user {}", activeSpreadsheetId, activeSheetName, telegramUserId); - } - break; - } - - case "GS_GET_ROW": { - Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS).orElse(null); - if (integration == null) { - log.warn("Skipping Google Sheets get row: no GOOGLE_SHEETS integration configured for bot {}", botId); - break; - } - String spreadsheetId = (String) action.get("spreadsheetId"); - String sheetName = (String) action.get("sheetName"); - String lookupColumn = (String) action.get("lookupColumn"); - String lookupValue = (String) action.get("lookupValue"); - List> mappings = (List>) action.get("columnMappings"); - - if (spreadsheetId != null && !spreadsheetId.isEmpty() && lookupColumn != null && !lookupColumn.isEmpty()) { - String activeSpreadsheetId = resolveSpreadsheetId(botId, spreadsheetId); - String activeSheetName = sheetName != null && !sheetName.trim().isEmpty() ? sheetName.trim() : "Sheet1"; - String resolvedLookupVal = resolveValue(lookupValue, sessionData, botUser).trim(); - - List> sheetValues = googleSheetsService.getSheetValues(botId, activeSpreadsheetId, activeSheetName); - if (sheetValues != null && !sheetValues.isEmpty()) { - List headers = sheetValues.get(0); - int lookupColIdx = -1; - for (int i = 0; i < headers.size(); i++) { - if (lookupColumn.equalsIgnoreCase(String.valueOf(headers.get(i)).trim())) { - lookupColIdx = i; - break; - } - } - - if (lookupColIdx != -1) { - for (int rowIndex = 1; rowIndex < sheetValues.size(); rowIndex++) { - List row = sheetValues.get(rowIndex); - if (row.size() > lookupColIdx) { - String cellVal = String.valueOf(row.get(lookupColIdx)).trim(); - if (cellVal.equalsIgnoreCase(resolvedLookupVal)) { - log.info("Found matching row at index {} in Google Sheets for bot user {}", rowIndex, telegramUserId); - if (mappings != null) { - for (Map m : mappings) { - String targetGoogleCol = m.get("column"); - String targetLaunchlyField = m.get("value"); - if (targetGoogleCol != null && targetLaunchlyField != null) { - int targetColIdx = -1; - for (int hIdx = 0; hIdx < headers.size(); hIdx++) { - if (targetGoogleCol.equalsIgnoreCase(String.valueOf(headers.get(hIdx)).trim())) { - targetColIdx = hIdx; - break; - } - } - if (targetColIdx != -1 && row.size() > targetColIdx) { - String targetValue = String.valueOf(row.get(targetColIdx)).trim(); - setContactField(botUser, botId, telegramUserId, targetLaunchlyField, targetValue, sessionData); - } - } - } - } - break; - } - } - } - } else { - log.warn("Lookup column '{}' not found in spreadsheet {} headers", lookupColumn, activeSpreadsheetId); - } - } - } - break; - } - - case "GS_UPDATE_ROW": { - Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS).orElse(null); - if (integration == null) { - log.warn("Skipping Google Sheets update row: no GOOGLE_SHEETS integration configured for bot {}", botId); - break; - } - String spreadsheetId = (String) action.get("spreadsheetId"); - String sheetName = (String) action.get("sheetName"); - String lookupColumn = (String) action.get("lookupColumn"); - String lookupValue = (String) action.get("lookupValue"); - List> mappings = (List>) action.get("columnMappings"); - - if (spreadsheetId != null && !spreadsheetId.isEmpty() && lookupColumn != null && !lookupColumn.isEmpty()) { - String activeSpreadsheetId = resolveSpreadsheetId(botId, spreadsheetId); - String activeSheetName = sheetName != null && !sheetName.trim().isEmpty() ? sheetName.trim() : "Sheet1"; - String resolvedLookupVal = resolveValue(lookupValue, sessionData, botUser).trim(); - - List> sheetValues = googleSheetsService.getSheetValues(botId, activeSpreadsheetId, activeSheetName); - if (sheetValues != null && !sheetValues.isEmpty()) { - List headers = sheetValues.get(0); - int lookupColIdx = -1; - for (int i = 0; i < headers.size(); i++) { - if (lookupColumn.equalsIgnoreCase(String.valueOf(headers.get(i)).trim())) { - lookupColIdx = i; - break; - } - } - - if (lookupColIdx != -1) { - for (int rowIndex = 1; rowIndex < sheetValues.size(); rowIndex++) { - List row = sheetValues.get(rowIndex); - if (row.size() > lookupColIdx) { - String cellVal = String.valueOf(row.get(lookupColIdx)).trim(); - if (cellVal.equalsIgnoreCase(resolvedLookupVal)) { - log.info("Updating matching row at index {} in Google Sheets for bot user {}", rowIndex, telegramUserId); - if (mappings != null) { - for (Map m : mappings) { - String targetGoogleCol = m.get("column"); - String targetValueExpr = m.get("value"); - if (targetGoogleCol != null && targetValueExpr != null && !targetValueExpr.trim().isEmpty()) { - int targetColIdx = -1; - for (int hIdx = 0; hIdx < headers.size(); hIdx++) { - if (targetGoogleCol.equalsIgnoreCase(String.valueOf(headers.get(hIdx)).trim())) { - targetColIdx = hIdx; - break; - } - } - if (targetColIdx != -1) { - String resolvedVal = resolveValue(targetValueExpr, sessionData, botUser); - String colLetter = getColumnLetter(targetColIdx); - String cellRef = colLetter + (rowIndex + 1); - googleSheetsService.updateCell(botId, activeSpreadsheetId, activeSheetName, cellRef, resolvedVal); - } - } - } - } - break; - } - } - } - } else { - log.warn("Lookup column '{}' not found in spreadsheet {} headers", lookupColumn, activeSpreadsheetId); - } - } - } - break; - } - - case "NOTIFY_ASSIGNEES": - case "NOTIFY_ASSIGNEE": { - User botOwner = botUser.getBot().getUser(); - if (botOwner != null) { - notificationService.sendAssignmentNotification(botOwner.getId(), botUser.getId()); - } else { - log.warn("Cannot send assignee notification: bot owner is null"); - } - break; - } - - case "MAILCHIMP_SUBSCRIBE": { - Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.MAILCHIMP).orElse(null); - if (integration != null && integration.isActive()) { - String email = resolveValue((String) action.get("email"), sessionData, botUser); - if (email == null || email.trim().isEmpty()) { - email = sessionData.getOrDefault("email", ""); - } - String firstName = resolveValue((String) action.get("firstName"), sessionData, botUser); - if (firstName == null || firstName.trim().isEmpty()) { - firstName = botUser.getFirstName() != null ? botUser.getFirstName() : ""; - } - String lastName = resolveValue((String) action.get("lastName"), sessionData, botUser); - if (lastName == null || lastName.trim().isEmpty()) { - lastName = botUser.getLastName() != null ? botUser.getLastName() : ""; - } - String phone = resolveValue((String) action.get("phone"), sessionData, botUser); - if (phone == null || phone.trim().isEmpty()) { - phone = sessionData.getOrDefault("phone", ""); - } - Object tagsObj = action.get("tags"); - List tags = null; - if (tagsObj instanceof List) { - tags = ((List) tagsObj).stream().map(String::valueOf).collect(Collectors.toList()); - } - if (email != null && !email.trim().isEmpty()) { - mailchimpService.addOrUpdateSubscriber(integration, email.trim(), firstName.trim(), lastName.trim(), phone.trim(), tags); - log.info("Triggered Mailchimp subscribe for user {} email {}", telegramUserId, email); - } - } else { - log.warn("Skipping Mailchimp subscribe: no active MAILCHIMP integration for bot {}", botId); - } - break; - } - - default: - log.warn("Unknown action type: {}", type); + BotActionHandler handler = handlerMap.get(type); + if (handler != null) { + handler.execute(type, action, botUser, sessionData); + } else { + log.warn("Unknown action type: {}", type); } } catch (Exception e) { log.error("Error executing action type {} in node {}: {}", type, node.id(), e.getMessage(), e); @@ -385,269 +77,4 @@ public String execute(FlowNode node, List edges, BotUser botUser, .map(FlowEdge::target) .orElse(null); } - - private String resolveSpreadsheetId(Long botId, String spreadsheetIdOrName) { - String value = spreadsheetIdOrName != null ? spreadsheetIdOrName.trim() : ""; - if (value.isEmpty()) { - return value; - } - - try { - List> spreadsheets = googleSheetsService.getSpreadsheets(botId); - for (Map spreadsheet : spreadsheets) { - String id = spreadsheet.get("id"); - String name = spreadsheet.get("name"); - if (value.equals(id) || (name != null && value.equalsIgnoreCase(name.trim()))) { - return id; - } - } - } catch (Exception e) { - log.warn("Failed to resolve spreadsheet '{}' by name for bot {}: {}", value, botId, e.getMessage()); - } - - return value; - } - - private String resolveValue(String text, Map variables, BotUser botUser) { - if (text == null) return ""; - String trimmed = text.trim(); - - if (trimmed.contains("+")) { - String[] parts = trimmed.split("\\+"); - List resolvedParts = new ArrayList<>(); - for (String part : parts) { - resolvedParts.add(resolveSingleValue(part.trim(), variables, botUser)); - } - return String.join("_", resolvedParts); - } - - return resolveSingleValue(trimmed, variables, botUser); - } - - private String resolveSingleValue(String text, Map variables, BotUser botUser) { - if (text == null) return ""; - String trimmed = text.trim(); - - if (trimmed.equalsIgnoreCase("First Name") || trimmed.equalsIgnoreCase("first_name")) { - return botUser.getFirstName() != null ? botUser.getFirstName() : ""; - } - if (trimmed.equalsIgnoreCase("Last Name") || trimmed.equalsIgnoreCase("last_name")) { - return botUser.getLastName() != null ? botUser.getLastName() : ""; - } - if (trimmed.equalsIgnoreCase("Telegram Username") || trimmed.equalsIgnoreCase("telegram_username") || trimmed.equalsIgnoreCase("username")) { - return botUser.getUsername() != null ? botUser.getUsername() : ""; - } - if (trimmed.equalsIgnoreCase("Phone")) { - return variables.getOrDefault("phone", ""); - } - if (trimmed.equalsIgnoreCase("Email")) { - return variables.getOrDefault("email", ""); - } - if (trimmed.equalsIgnoreCase("Contact Id") || trimmed.equalsIgnoreCase("contact_id")) { - return botUser.getId() != null ? String.valueOf(botUser.getId()) : ""; - } - if (trimmed.equalsIgnoreCase("Subscribed")) { - return variables.getOrDefault("telegram_opt_in", "false"); - } - if (trimmed.equalsIgnoreCase("Last Reply Type") || trimmed.equalsIgnoreCase("last_reply_type")) { - return variables.getOrDefault("last_reply_type", "text"); - } - if (trimmed.equalsIgnoreCase("Telegram User ID") || trimmed.equalsIgnoreCase("telegram_user_id")) { - return botUser.getTelegramId() != null ? String.valueOf(botUser.getTelegramId()) : ""; - } - if (trimmed.equalsIgnoreCase("Opted-in for Telegram") || trimmed.equalsIgnoreCase("telegram_opt_in")) { - return variables.getOrDefault("telegram_opt_in", "false"); - } - - try { - if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { - ObjectMapper objectMapper = new ObjectMapper(); - Map metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); - Map customFields = (Map) metaMap.get("customFields"); - if (customFields != null) { - for (Map.Entry entry : customFields.entrySet()) { - if (entry.getKey().equalsIgnoreCase(trimmed)) { - return entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; - } - } - } - } - } catch (Exception e) { - log.error("Error reading custom fields: {}", e.getMessage()); - } - - try { - List allTags = tagRepository.findByBotId(botUser.getBot().getId()); - for (Tag t : allTags) { - if (t.getName().equalsIgnoreCase(trimmed)) { - boolean hasTag = botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), t.getId()); - return String.valueOf(hasTag); - } - } - } catch (Exception e) { - log.error("Error reading tags: {}", e.getMessage()); - } - - return resolvePlaceholders(text, variables, botUser); - } - - private String replacePlaceholder(String text, String placeholderName, String value) { - if (text == null || placeholderName == null) return text; - String resolvedVal = value != null ? value : ""; - try { - return text.replaceAll("(?i)" + Pattern.quote("{{" + placeholderName + "}}"), - Matcher.quoteReplacement(resolvedVal)); - } catch (Exception e) { - return text.replace("{{" + placeholderName + "}}", resolvedVal) - .replace("{{" + placeholderName.toLowerCase() + "}}", resolvedVal); - } - } - - private String resolvePlaceholders(String text, Map variables, BotUser botUser) { - if (text == null) return ""; - String result = text; - - result = replacePlaceholder(result, "First Name", botUser.getFirstName()); - result = replacePlaceholder(result, "first_name", botUser.getFirstName()); - - result = replacePlaceholder(result, "Last Name", botUser.getLastName()); - result = replacePlaceholder(result, "last_name", botUser.getLastName()); - - result = replacePlaceholder(result, "Telegram Username", botUser.getUsername()); - result = replacePlaceholder(result, "telegram_username", botUser.getUsername()); - result = replacePlaceholder(result, "username", botUser.getUsername()); - - result = replacePlaceholder(result, "Telegram User ID", botUser.getTelegramId() != null ? String.valueOf(botUser.getTelegramId()) : null); - result = replacePlaceholder(result, "telegram_user_id", botUser.getTelegramId() != null ? String.valueOf(botUser.getTelegramId()) : null); - - result = replacePlaceholder(result, "Contact ID", botUser.getId() != null ? String.valueOf(botUser.getId()) : null); - result = replacePlaceholder(result, "Contact Id", botUser.getId() != null ? String.valueOf(botUser.getId()) : null); - result = replacePlaceholder(result, "contact_id", botUser.getId() != null ? String.valueOf(botUser.getId()) : null); - - result = replacePlaceholder(result, "Phone", variables.get("phone")); - result = replacePlaceholder(result, "phone", variables.get("phone")); - - result = replacePlaceholder(result, "Email", variables.get("email")); - result = replacePlaceholder(result, "email", variables.get("email")); - - result = replacePlaceholder(result, "Subscribed", variables.get("telegram_opt_in")); - result = replacePlaceholder(result, "telegram_opt_in", variables.get("telegram_opt_in")); - result = replacePlaceholder(result, "Opted-in for Telegram", variables.get("telegram_opt_in")); - - result = replacePlaceholder(result, "Last Reply Type", variables.get("last_reply_type")); - result = replacePlaceholder(result, "last_reply_type", variables.get("last_reply_type")); - - try { - if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { - ObjectMapper objectMapper = new ObjectMapper(); - Map metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); - Map customFields = (Map) metaMap.get("customFields"); - if (customFields != null) { - for (Map.Entry entry : customFields.entrySet()) { - String valStr = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; - result = replacePlaceholder(result, entry.getKey(), valStr); - } - } - } - } catch (Exception e) { - log.error("Error replacing custom field placeholders for user {}: {}", botUser.getId(), e.getMessage()); - } - - try { - List allTags = tagRepository.findByBotId(botUser.getBot().getId()); - for (Tag t : allTags) { - boolean hasTag = botUserTagRepository.existsByBotUserIdAndTagId(botUser.getId(), t.getId()); - String val = String.valueOf(hasTag); - result = replacePlaceholder(result, "tag:" + t.getName(), val); - result = replacePlaceholder(result, "tag." + t.getName(), val); - result = replacePlaceholder(result, t.getName(), val); - } - } catch (Exception e) { - log.error("Error replacing tag placeholders for user {}: {}", botUser.getId(), e.getMessage()); - } - - return result; - } - - @SuppressWarnings("unchecked") - private void updateContactCustomField(BotUser botUser, String fieldName, String fieldValue) { - try { - ObjectMapper objectMapper = new ObjectMapper(); - Map metaMap = new HashMap<>(); - if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { - try { - metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); - } catch (Exception e) { - log.error("Failed to parse metadata: {}", e.getMessage()); - } - } - Map customFields = (Map) metaMap.get("customFields"); - if (customFields == null) { - customFields = new HashMap<>(); - } - if (fieldValue == null) { - customFields.remove(fieldName); - } else { - customFields.put(fieldName, fieldValue); - } - metaMap.put("customFields", customFields); - botUser.setMetadata(objectMapper.writeValueAsString(metaMap)); - botUserRepository.save(botUser); - } catch (Exception e) { - log.error("Failed to update contact custom field: {}", e.getMessage(), e); - } - } - - @SuppressWarnings("unchecked") - private void updateContactMetadataField(BotUser botUser, String key, Object value) { - try { - ObjectMapper objectMapper = new ObjectMapper(); - Map metaMap = new HashMap<>(); - if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { - try { - metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); - } catch (Exception e) { - log.error("Failed to parse metadata: {}", e.getMessage()); - } - } - metaMap.put(key, value); - botUser.setMetadata(objectMapper.writeValueAsString(metaMap)); - botUserRepository.save(botUser); - } catch (Exception e) { - log.error("Failed to update contact metadata field: {}", e.getMessage(), e); - } - } - - private void setContactField(BotUser botUser, Long botId, Long telegramUserId, String fieldName, String value, Map sessionData) { - String trimmed = fieldName.trim(); - if (trimmed.equalsIgnoreCase("first_name") || trimmed.equalsIgnoreCase("First Name")) { - botUser.setFirstName(value); - botUserRepository.save(botUser); - } else if (trimmed.equalsIgnoreCase("last_name") || trimmed.equalsIgnoreCase("Last Name")) { - botUser.setLastName(value); - botUserRepository.save(botUser); - } else if (trimmed.equalsIgnoreCase("username") || trimmed.equalsIgnoreCase("telegram_username") || trimmed.equalsIgnoreCase("Telegram Username")) { - botUser.setUsername(value); - botUserRepository.save(botUser); - } else if (trimmed.equalsIgnoreCase("phone") || trimmed.equalsIgnoreCase("Phone")) { - stateService.setSessionData(botId, telegramUserId, "phone", value); - updateContactMetadataField(botUser, "phone", value); - } else if (trimmed.equalsIgnoreCase("email") || trimmed.equalsIgnoreCase("Email")) { - stateService.setSessionData(botId, telegramUserId, "email", value); - updateContactMetadataField(botUser, "email", value); - } else { - stateService.setSessionData(botId, telegramUserId, trimmed, value); - updateContactCustomField(botUser, trimmed, value); - } - } - - private String getColumnLetter(int colIndex) { - StringBuilder sb = new StringBuilder(); - int temp = colIndex; - while (temp >= 0) { - sb.insert(0, (char) ('A' + (temp % 26))); - temp = (temp / 26) - 1; - } - return sb.toString(); - } } diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/ApiCallNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/ApiCallNodeExecutor.java index 367ca707..2de5ec39 100644 --- a/backend/src/main/java/com/launchly/bot/engine/executor/ApiCallNodeExecutor.java +++ b/backend/src/main/java/com/launchly/bot/engine/executor/ApiCallNodeExecutor.java @@ -23,9 +23,11 @@ @RequiredArgsConstructor public class ApiCallNodeExecutor implements NodeExecutor { + private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(5); + private final BotDialogStateService stateService; private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) + .connectTimeout(HTTP_TIMEOUT) .build(); @Override @@ -56,7 +58,7 @@ public String execute(FlowNode node, List edges, BotUser botUser, HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(resolvedUrl)) - .timeout(Duration.ofSeconds(5)); + .timeout(HTTP_TIMEOUT); if (data.containsKey("headers") && data.get("headers") instanceof Map) { Map headers = (Map) data.get("headers"); diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/CommandNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/CommandNodeExecutor.java new file mode 100644 index 00000000..e855f101 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/CommandNodeExecutor.java @@ -0,0 +1,29 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import java.util.List; + +@Component +public class CommandNodeExecutor implements NodeExecutor { + + @Override + public NodeType getType() { + return NodeType.COMMAND; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + return edges.stream() + .filter(e -> e.source().equals(node.id())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/ConditionNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/ConditionNodeExecutor.java index 25965ef6..2b6b6e5e 100644 --- a/backend/src/main/java/com/launchly/bot/engine/executor/ConditionNodeExecutor.java +++ b/backend/src/main/java/com/launchly/bot/engine/executor/ConditionNodeExecutor.java @@ -129,6 +129,15 @@ private String resolveVariable(String variable, BotUser botUser, Map edges, BotUser botUser, + Update update, TelegramClient client) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + String chatId = resolveChatId(update, botUser); + String chatScope = resolveChatScope(update); + Map data = node.data(); + + long duration = 1; + String unit = "MINUTES"; + String blockMessage = messageUtils.getMessage("bot.cooldown.default_message"); + String cooldownKey = "cooldown_" + node.id(); + + if (data != null) { + if (data.get("duration") != null) { + duration = parsePositiveLong(data.get("duration"), 1); + } + if (data.get("unit") instanceof String s && !s.trim().isEmpty()) { + unit = s.trim().toUpperCase(); + } + if (data.get("blockMessage") instanceof String s && !s.trim().isEmpty()) { + blockMessage = s.trim(); + } + if (data.get("cooldownKey") instanceof String s && !s.trim().isEmpty()) { + cooldownKey = "cooldown_" + s.trim(); + } + } + + String scopedCooldownKey = (!"private".equalsIgnoreCase(chatScope) ? chatScope + "_" : "") + cooldownKey; + + long durationMs = switch (unit) { + case "SECONDS" -> duration * 1000L; + case "HOURS" -> duration * 3600 * 1000L; + case "DAYS" -> duration * 86400 * 1000L; + default -> duration * 60 * 1000L; + }; + + Map sessionData = stateService.getSessionData(botId, telegramUserId); + long lastPassedTime = getLastPassedTimestamp(botUser, scopedCooldownKey, sessionData); + long now = System.currentTimeMillis(); + + if (lastPassedTime > 0) { + long elapsed = now - lastPassedTime; + if (elapsed < durationMs) { + long remainingMs = durationMs - elapsed; + String remainingFormatted = formatRemainingTime(remainingMs); + + long remSeconds = TimeUnit.MILLISECONDS.toSeconds(remainingMs) % 60; + long remMinutes = TimeUnit.MILLISECONDS.toMinutes(remainingMs) % 60; + long remHours = TimeUnit.MILLISECONDS.toHours(remainingMs) % 24; + long remDays = TimeUnit.MILLISECONDS.toDays(remainingMs); + long totalSeconds = Math.max(1, TimeUnit.MILLISECONDS.toSeconds(remainingMs)); + long totalMinutes = Math.max(1, TimeUnit.MILLISECONDS.toMinutes(remainingMs)); + + String msg = blockMessage + .replace("{remaining}", remainingFormatted) + .replace("{{remaining}}", remainingFormatted) + .replace("{seconds}", String.valueOf(remSeconds > 0 ? remSeconds : totalSeconds)) + .replace("{{seconds}}", String.valueOf(remSeconds > 0 ? remSeconds : totalSeconds)) + .replace("{total_seconds}", String.valueOf(totalSeconds)) + .replace("{{total_seconds}}", String.valueOf(totalSeconds)) + .replace("{minutes}", String.valueOf(remMinutes > 0 ? remMinutes : totalMinutes)) + .replace("{{minutes}}", String.valueOf(remMinutes > 0 ? remMinutes : totalMinutes)) + .replace("{hours}", String.valueOf(remHours)) + .replace("{{hours}}", String.valueOf(remHours)) + .replace("{days}", String.valueOf(remDays)) + .replace("{{days}}", String.valueOf(remDays)); + + msg = placeholderResolver.resolveValue(msg, sessionData, botUser); + + try { + SendMessage sendMessage = SendMessage.builder() + .chatId(chatId) + .text(msg) + .build(); + client.execute(sendMessage); + } catch (TelegramApiException e) { + log.error("Failed to send cooldown message to chat {}: {}", chatId, e.getMessage()); + } + + log.info("Cooldown ACTIVE for botUser {} on key '{}' (scope '{}'). Remaining: {}ms", + botUser.getId(), scopedCooldownKey, chatScope, remainingMs); + + return null; + } + } + + String nowStr = String.valueOf(now); + stateService.setSessionData(botId, telegramUserId, scopedCooldownKey, nowStr); + contactManager.updateContactCooldown(botUser, scopedCooldownKey, nowStr); + + log.info("Cooldown PASSED for botUser {} on key '{}' (scope '{}'). Proceeding to next node.", + botUser.getId(), scopedCooldownKey, chatScope); + + return edges.stream() + .filter(e -> e.source().equals(node.id())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } + + private String resolveChatId(Update update, BotUser botUser) { + if (update != null) { + if (update.hasMessage() && update.getMessage().getChat() != null) { + return update.getMessage().getChatId().toString(); + } + if (update.hasCallbackQuery() && update.getCallbackQuery().getMessage() != null && update.getCallbackQuery().getMessage().getChat() != null) { + return update.getCallbackQuery().getMessage().getChatId().toString(); + } + if (update.hasChannelPost() && update.getChannelPost().getChat() != null) { + return update.getChannelPost().getChatId().toString(); + } + } + return botUser != null && botUser.getTelegramId() != null ? botUser.getTelegramId().toString() : "0"; + } + + private String resolveChatScope(Update update) { + if (update != null) { + if (update.hasMessage() && update.getMessage().getChat() != null) { + String type = update.getMessage().getChat().getType(); + if ("private".equalsIgnoreCase(type)) { + return "private"; + } + return update.getMessage().getChatId().toString(); + } + if (update.hasCallbackQuery() && update.getCallbackQuery().getMessage() != null && update.getCallbackQuery().getMessage().getChat() != null) { + String type = update.getCallbackQuery().getMessage().getChat().getType(); + if ("private".equalsIgnoreCase(type)) { + return "private"; + } + return update.getCallbackQuery().getMessage().getChatId().toString(); + } + if (update.hasChannelPost() && update.getChannelPost().getChat() != null) { + return update.getChannelPost().getChatId().toString(); + } + } + return "private"; + } + + private long getLastPassedTimestamp(BotUser botUser, String key, Map sessionData) { + String sessionVal = sessionData.get(key); + if (sessionVal != null && !sessionVal.trim().isEmpty()) { + try { + return Long.parseLong(sessionVal.trim()); + } catch (NumberFormatException ignored) {} + } + + try { + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + Map meta = objectMapper.readValue(botUser.getMetadata(), Map.class); + Map cooldowns = (Map) meta.get("cooldowns"); + if (cooldowns != null && cooldowns.get(key) != null) { + Object valObj = cooldowns.get(key); + if (valObj instanceof Number num) { + return num.longValue(); + } + return Long.parseLong(valObj.toString().trim()); + } + Map custom = (Map) meta.get("customFields"); + if (custom != null && custom.get(key) != null) { + Object valObj = custom.get(key); + if (valObj instanceof Number num) { + return num.longValue(); + } + return Long.parseLong(valObj.toString().trim()); + } + } + } catch (Exception ignored) {} + + return 0L; + } + + private String formatRemainingTime(long remainingMs) { + long seconds = TimeUnit.MILLISECONDS.toSeconds(remainingMs); + if (seconds <= 0) { + return messageUtils.getMessage("time.less_than_second"); + } + long days = TimeUnit.MILLISECONDS.toDays(remainingMs); + long hours = TimeUnit.MILLISECONDS.toHours(remainingMs) % 24; + long minutes = TimeUnit.MILLISECONDS.toMinutes(remainingMs) % 60; + long remSec = seconds % 60; + + if (days > 0) { + if (hours > 0) { + return messageUtils.getMessage("time.days_hours", days, hours); + } + return messageUtils.getMessage("time.days", days); + } + if (hours > 0) { + if (minutes > 0) { + return messageUtils.getMessage("time.hours_minutes", hours, minutes); + } + return messageUtils.getMessage("time.hours", hours); + } + if (minutes > 0) { + if (remSec > 0) { + return messageUtils.getMessage("time.minutes_seconds", minutes, remSec); + } + return messageUtils.getMessage("time.minutes", minutes); + } + return messageUtils.getMessage("time.seconds", seconds); + } + + private long parsePositiveLong(Object obj, long fallback) { + if (obj == null) return fallback; + if (obj instanceof Number num) return Math.max(0, num.longValue()); + try { + return Math.max(0, Long.parseLong(obj.toString().trim())); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/InteractionNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/InteractionNodeExecutor.java new file mode 100644 index 00000000..4feee607 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/InteractionNodeExecutor.java @@ -0,0 +1,160 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.BotUserInteraction; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.repository.BotUserInteractionRepository; +import com.launchly.bot.service.BotDialogStateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class InteractionNodeExecutor implements NodeExecutor { + + private static final String DEFAULT_TARGET_ID_VAR = "{found_user.telegram_id}"; + private static final String DEFAULT_INTERACTION_TYPE = "like"; + private static final String DEFAULT_MUTUAL_TYPE = "like"; + + private static final String KEY_TARGET_USER_ID = "targetUserId"; + private static final String KEY_TARGET_TELEGRAM_ID = "targetTelegramId"; + private static final String KEY_INTERACTION_TYPE = "interactionType"; + private static final String KEY_CHECK_MUTUAL = "checkMutual"; + private static final String KEY_MUTUAL_TYPE = "mutualType"; + + private static final String HANDLE_MUTUAL = "mutual"; + private static final String HANDLE_SAVED = "saved"; + private static final String HANDLE_NEXT = "next"; + + private final BotUserInteractionRepository interactionRepository; + private final BotDialogStateService stateService; + + @Override + public NodeType getType() { + return NodeType.INTERACTION; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + Long botId = botUser.getBot().getId(); + Long currentTelegramId = botUser.getTelegramId(); + Map data = node.data(); + + log.info("Executing Interaction Node {} for bot user {}", node.id(), currentTelegramId); + + String targetIdVar = DEFAULT_TARGET_ID_VAR; + String interactionType = DEFAULT_INTERACTION_TYPE; + boolean checkMutual = true; + String mutualType = DEFAULT_MUTUAL_TYPE; + + if (data != null) { + if (data.get(KEY_TARGET_USER_ID) instanceof String s && !s.trim().isEmpty()) { + targetIdVar = s.trim(); + } else if (data.get(KEY_TARGET_TELEGRAM_ID) instanceof String s && !s.trim().isEmpty()) { + targetIdVar = s.trim(); + } + if (data.get(KEY_INTERACTION_TYPE) instanceof String s && !s.trim().isEmpty()) { + interactionType = s.trim().toLowerCase(); + } + if (data.get(KEY_CHECK_MUTUAL) != null) { + checkMutual = Boolean.parseBoolean(data.get(KEY_CHECK_MUTUAL).toString()); + } + if (data.get(KEY_MUTUAL_TYPE) instanceof String s && !s.trim().isEmpty()) { + mutualType = s.trim().toLowerCase(); + } + } + + Map sessionData = stateService.getSessionData(botId, currentTelegramId); + Long targetTelegramId = resolveTargetTelegramId(targetIdVar, sessionData); + + if (targetTelegramId != null && !targetTelegramId.equals(currentTelegramId)) { + Optional existing = interactionRepository + .findByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType( + botId, currentTelegramId, targetTelegramId, interactionType + ); + + if (existing.isEmpty()) { + BotUserInteraction interaction = BotUserInteraction.builder() + .bot(botUser.getBot()) + .sourceTelegramId(currentTelegramId) + .targetTelegramId(targetTelegramId) + .interactionType(interactionType) + .build(); + interactionRepository.save(interaction); + log.info("Saved interaction: {} -> {} ({})", currentTelegramId, targetTelegramId, interactionType); + } + + if (checkMutual) { + boolean isMutual = interactionRepository + .existsByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType( + botId, targetTelegramId, currentTelegramId, mutualType + ); + + if (isMutual) { + log.info("Mutual interaction found between {} and {} for type {}", currentTelegramId, targetTelegramId, mutualType); + String mutualTarget = findTarget(edges, node.id(), HANDLE_MUTUAL); + if (mutualTarget != null) { + return mutualTarget; + } + } + } + } else { + log.warn("Invalid or missing target Telegram ID for interaction node {}", node.id()); + } + + String savedTarget = findTarget(edges, node.id(), HANDLE_SAVED); + if (savedTarget != null) { + return savedTarget; + } + return findTarget(edges, node.id(), HANDLE_NEXT); + } + + private Long resolveTargetTelegramId(String rawVar, Map sessionData) { + if (rawVar == null || rawVar.trim().isEmpty()) return null; + String val = rawVar.trim(); + String key = val; + if (val.startsWith("{") && val.endsWith("}")) { + key = val.substring(1, val.length() - 1).trim(); + } + if (sessionData != null) { + if (sessionData.containsKey(key)) { + val = sessionData.get(key); + } else if (sessionData.containsKey(val)) { + val = sessionData.get(val); + } else { + for (Map.Entry entry : sessionData.entrySet()) { + if (entry.getKey().equalsIgnoreCase(key) || entry.getKey().equalsIgnoreCase(val)) { + val = entry.getValue(); + break; + } + } + } + } + try { + return Long.parseLong(val.trim()); + } catch (Exception e) { + return null; + } + } + + private String findTarget(List edges, String nodeId, String handleId) { + if (edges == null) return null; + return edges.stream() + .filter(e -> e.source().equals(nodeId)) + .filter(e -> handleId.equals(e.sourceHandle())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/JoinRequestNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/JoinRequestNodeExecutor.java new file mode 100644 index 00000000..1f1e00d8 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/JoinRequestNodeExecutor.java @@ -0,0 +1,30 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.List; + +@Component +public class JoinRequestNodeExecutor implements NodeExecutor { + + @Override + public NodeType getType() { + return NodeType.JOIN_REQUEST; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + return edges.stream() + .filter(e -> e.source().equals(node.id())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/LeadNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/LeadNodeExecutor.java index 0bbeded2..2c641354 100644 --- a/backend/src/main/java/com/launchly/bot/engine/executor/LeadNodeExecutor.java +++ b/backend/src/main/java/com/launchly/bot/engine/executor/LeadNodeExecutor.java @@ -38,7 +38,7 @@ public String execute(FlowNode node, List edges, BotUser botUser, Map sessionData = stateService.getSessionData(botId, telegramUserId); String name = data != null && data.containsKey("name") ? (String) data.get("name") - : sessionData.getOrDefault("name", botUser.getFirstName() + " " + botUser.getLastName()); + : sessionData.getOrDefault("name", botUser.getDisplayName()); String email = data != null && data.containsKey("email") ? (String) data.get("email") : sessionData.getOrDefault("email", ""); String phone = data != null && data.containsKey("phone") ? (String) data.get("phone") diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/LeaderboardNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/LeaderboardNodeExecutor.java new file mode 100644 index 00000000..6334a759 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/LeaderboardNodeExecutor.java @@ -0,0 +1,261 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.BotDialogStateService; +import com.launchly.common.utils.MessageUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import tools.jackson.databind.ObjectMapper; +import java.util.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LeaderboardNodeExecutor implements NodeExecutor { + + private final BotUserRepository botUserRepository; + private final BotDialogStateService stateService; + private final MessageUtils messageUtils; + private final ObjectMapper objectMapper; + + private record UserScoreEntry(BotUser botUser, double score, String displayName) {} + + @Override + public NodeType getType() { + return NodeType.LEADERBOARD; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + Map data = node.data(); + + String targetField = "points"; + int limit = 10; + String sortOrder = "DESC"; + boolean showRank = true; + boolean showScores = true; + String outputVariable = "leaderboard"; + String userRankVariable = "user_rank"; + String userScoreVariable = "user_score"; + String customHeader = null; + + if (data != null) { + if (data.get("targetField") instanceof String s && !s.trim().isEmpty()) { + targetField = s.trim(); + } + if (data.get("limit") != null) { + limit = parsePositiveInt(data.get("limit"), 10); + } + if (data.get("sortOrder") instanceof String s && !s.trim().isEmpty()) { + sortOrder = s.trim().toUpperCase(); + } + if (data.get("showRank") != null) { + showRank = Boolean.parseBoolean(data.get("showRank").toString()); + } + if (data.get("showScores") != null) { + showScores = Boolean.parseBoolean(data.get("showScores").toString()); + } + if (data.get("outputVariable") instanceof String s && !s.trim().isEmpty()) { + outputVariable = s.trim(); + } + if (data.get("userRankVariable") instanceof String s && !s.trim().isEmpty()) { + userRankVariable = s.trim(); + } + if (data.get("userScoreVariable") instanceof String s && !s.trim().isEmpty()) { + userScoreVariable = s.trim(); + } + if (data.get("customHeader") instanceof String s && !s.trim().isEmpty()) { + customHeader = s.trim(); + } + } + + String chatScope = resolveChatScope(update); + List allBotUsers = botUserRepository.findAllByBotId(botId); + List entries = new ArrayList<>(); + + for (BotUser u : allBotUsers) { + double score = extractUserScore(u, chatScope, targetField); + String displayName = resolveDisplayName(u); + if (!"private".equalsIgnoreCase(chatScope)) { + if (score > 0 || u.getId().equals(botUser.getId())) { + entries.add(new UserScoreEntry(u, score, displayName)); + } + } else { + entries.add(new UserScoreEntry(u, score, displayName)); + } + } + + boolean isAscending = "ASC".equalsIgnoreCase(sortOrder); + if (isAscending) { + entries.sort(Comparator.comparingDouble(UserScoreEntry::score)); + } else { + entries.sort((a, b) -> Double.compare(b.score(), a.score())); + } + + int currentUserRank = 0; + double currentUserScore = 0.0; + + for (int i = 0; i < entries.size(); i++) { + UserScoreEntry entry = entries.get(i); + if (entry.botUser().getId().equals(botUser.getId())) { + currentUserRank = i + 1; + currentUserScore = entry.score(); + break; + } + } + + StringBuilder sb = new StringBuilder(); + if (customHeader != null && !customHeader.isEmpty()) { + sb.append(customHeader).append("\n"); + } + + int topCount = Math.min(limit, entries.size()); + if (topCount == 0) { + sb.append(messageUtils.getMessage("bot.leaderboard.empty")); + } else { + for (int i = 0; i < topCount; i++) { + UserScoreEntry entry = entries.get(i); + int rank = i + 1; + StringBuilder line = new StringBuilder(); + + if (showRank) { + line.append(rank).append(". "); + } + + line.append(entry.displayName()); + + if (showScores) { + line.append(" — ").append(formatNumber(entry.score())); + } + + sb.append(line); + if (i < topCount - 1) { + sb.append("\n"); + } + } + } + + String fullLeaderboard = sb.toString(); + String formattedScore = formatNumber(currentUserScore); + String rankStr = currentUserRank > 0 ? String.valueOf(currentUserRank) : "—"; + + stateService.setSessionData(botId, telegramUserId, targetField, fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, outputVariable, fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, "leaderboard", fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, "top_list", fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, "top", fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, "rating", fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, targetField + "_leaderboard", fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, targetField + "_top", fullLeaderboard); + stateService.setSessionData(botId, telegramUserId, userRankVariable, rankStr); + stateService.setSessionData(botId, telegramUserId, "user_rank", rankStr); + stateService.setSessionData(botId, telegramUserId, "rank", rankStr); + stateService.setSessionData(botId, telegramUserId, userScoreVariable, formattedScore); + stateService.setSessionData(botId, telegramUserId, "user_score", formattedScore); + + log.info("Leaderboard computed for botId={} in chatScope '{}': {} entries, currentUser {} rank={}", + botId, chatScope, entries.size(), telegramUserId, rankStr); + + return edges.stream() + .filter(e -> e.source().equals(node.id())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } + + private String resolveChatScope(Update update) { + if (update != null) { + if (update.hasMessage() && update.getMessage().getChat() != null) { + String type = update.getMessage().getChat().getType(); + if ("private".equalsIgnoreCase(type)) { + return "private"; + } + return update.getMessage().getChatId().toString(); + } + if (update.hasCallbackQuery() && update.getCallbackQuery().getMessage() != null && update.getCallbackQuery().getMessage().getChat() != null) { + String type = update.getCallbackQuery().getMessage().getChat().getType(); + if ("private".equalsIgnoreCase(type)) { + return "private"; + } + return update.getCallbackQuery().getMessage().getChatId().toString(); + } + if (update.hasChannelPost() && update.getChannelPost().getChat() != null) { + return update.getChannelPost().getChatId().toString(); + } + } + return "private"; + } + + @SuppressWarnings("unchecked") + private double extractUserScore(BotUser botUser, String chatScope, String fieldName) { + try { + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + Map meta = objectMapper.readValue(botUser.getMetadata(), Map.class); + if (chatScope != null && !chatScope.isEmpty() && !"private".equalsIgnoreCase(chatScope)) { + Map chatCustomFields = (Map) meta.get("chatCustomFields"); + if (chatCustomFields != null) { + Map groupFields = (Map) chatCustomFields.get(chatScope); + if (groupFields != null && groupFields.get(fieldName) != null) { + Object valObj = groupFields.get(fieldName); + if (valObj instanceof Number num) { + return num.doubleValue(); + } + return Double.parseDouble(valObj.toString().trim()); + } + } + return 0.0; + } else { + Map custom = (Map) meta.get("customFields"); + if (custom != null && custom.get(fieldName) != null) { + Object valObj = custom.get(fieldName); + if (valObj instanceof Number num) { + return num.doubleValue(); + } + return Double.parseDouble(valObj.toString().trim()); + } + } + } + } catch (Exception ignored) {} + return 0.0; + } + + private String resolveDisplayName(BotUser u) { + if (u.getUsername() != null && !u.getUsername().trim().isEmpty()) { + return "@" + u.getUsername().trim().replaceFirst("^@", ""); + } + String first = u.getFirstName() != null ? u.getFirstName().trim() : ""; + String last = u.getLastName() != null ? u.getLastName().trim() : ""; + String full = (first + " " + last).trim(); + if (!full.isEmpty()) { + return full; + } + return "User #" + (u.getTelegramId() != null ? u.getTelegramId() : u.getId()); + } + + private int parsePositiveInt(Object obj, int fallback) { + if (obj == null) return fallback; + if (obj instanceof Number num) return Math.max(1, num.intValue()); + try { + return Math.max(1, Integer.parseInt(obj.toString().trim())); + } catch (NumberFormatException e) { + return fallback; + } + } + + private String formatNumber(double val) { + if (val == Math.floor(val) && !Double.isInfinite(val)) { + return String.valueOf((long) val); + } + return String.format(Locale.US, "%.2f", val); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/MathNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/MathNodeExecutor.java new file mode 100644 index 00000000..d969fb1f --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/MathNodeExecutor.java @@ -0,0 +1,226 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.action.ActionContactManager; +import com.launchly.bot.engine.action.ActionPlaceholderResolver; +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.service.BotDialogStateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import tools.jackson.databind.ObjectMapper; +import java.security.SecureRandom; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MathNodeExecutor implements NodeExecutor { + + private final BotDialogStateService stateService; + private final ActionContactManager contactManager; + private final ActionPlaceholderResolver placeholderResolver; + private final ObjectMapper objectMapper; + private final SecureRandom random = new SecureRandom(); + + @Override + public NodeType getType() { + return NodeType.MATH; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + Map data = node.data(); + + if (data == null) { + return getDefaultTarget(edges, node.id()); + } + + String targetField = (String) data.get("targetField"); + if (targetField == null || targetField.trim().isEmpty()) { + targetField = "points"; + } + targetField = targetField.trim(); + + String valueField = (String) data.get("valueField"); + if (valueField == null || valueField.trim().isEmpty()) { + valueField = (String) data.get("resultVariable"); + } + if (valueField != null) { + valueField = valueField.trim(); + } + + String operationMode = (String) data.getOrDefault("operationMode", "RANDOM"); + String operationType = (String) data.getOrDefault("operationType", "ADD"); + + Map sessionData = stateService.getSessionData(botId, telegramUserId); + String chatScope = resolveChatScope(update); + + double curVal = getCurrentNumericValue(botUser, chatScope, targetField, sessionData); + double operand = calculateOperand(data, operationMode, sessionData, botUser); + + double newVal; + switch (operationType != null ? operationType.toUpperCase() : "ADD") { + case "SUBTRACT" -> newVal = curVal - operand; + case "SET" -> newVal = operand; + case "MULTIPLY" -> newVal = curVal * operand; + case "DIVIDE" -> newVal = (operand != 0) ? curVal / operand : curVal; + case "ADD" -> newVal = curVal + operand; + default -> newVal = curVal + operand; + } + + String formattedNewVal = formatNumber(newVal); + String formattedOperand = formatNumber(operand); + + contactManager.updateContactCustomField(botUser, chatScope, targetField, formattedNewVal); + stateService.setSessionData(botId, telegramUserId, targetField, formattedNewVal); + + if (valueField != null && !valueField.isEmpty()) { + contactManager.updateContactCustomField(botUser, chatScope, valueField, formattedOperand); + stateService.setSessionData(botId, telegramUserId, valueField, formattedOperand); + } + + stateService.setSessionData(botId, telegramUserId, targetField + "_change", formattedOperand); + stateService.setSessionData(botId, telegramUserId, "awarded_points", formattedOperand); + + log.info("Math operation for botUser {} in chatScope '{}': sumField '{}' (new={}), valueField '{}' (val={})", + botUser.getId(), chatScope, targetField, formattedNewVal, valueField, formattedOperand); + + return getDefaultTarget(edges, node.id()); + } + + private String resolveChatScope(Update update) { + if (update != null) { + if (update.hasMessage() && update.getMessage().getChat() != null) { + String type = update.getMessage().getChat().getType(); + if ("private".equalsIgnoreCase(type)) { + return "private"; + } + return update.getMessage().getChatId().toString(); + } + if (update.hasCallbackQuery() && update.getCallbackQuery().getMessage() != null && update.getCallbackQuery().getMessage().getChat() != null) { + String type = update.getCallbackQuery().getMessage().getChat().getType(); + if ("private".equalsIgnoreCase(type)) { + return "private"; + } + return update.getCallbackQuery().getMessage().getChatId().toString(); + } + if (update.hasChannelPost() && update.getChannelPost().getChat() != null) { + return update.getChannelPost().getChatId().toString(); + } + } + return "private"; + } + + @SuppressWarnings("unchecked") + private double getCurrentNumericValue(BotUser botUser, String chatScope, String fieldName, Map sessionData) { + try { + if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + Map meta = objectMapper.readValue(botUser.getMetadata(), Map.class); + if (chatScope != null && !chatScope.isEmpty() && !"private".equalsIgnoreCase(chatScope)) { + Map chatCustomFields = (Map) meta.get("chatCustomFields"); + if (chatCustomFields != null) { + Map groupFields = (Map) chatCustomFields.get(chatScope); + if (groupFields != null && groupFields.get(fieldName) != null) { + Object valObj = groupFields.get(fieldName); + if (valObj instanceof Number num) { + return num.doubleValue(); + } + return Double.parseDouble(valObj.toString().trim()); + } + } + return 0.0; + } else { + Map custom = (Map) meta.get("customFields"); + if (custom != null && custom.get(fieldName) != null) { + Object valObj = custom.get(fieldName); + if (valObj instanceof Number num) { + return num.doubleValue(); + } + return Double.parseDouble(valObj.toString().trim()); + } + } + } + } catch (Exception ignored) {} + + String sessionVal = sessionData.get(fieldName); + if (sessionVal != null && !sessionVal.trim().isEmpty()) { + try { + return Double.parseDouble(sessionVal.trim()); + } catch (NumberFormatException ignored) {} + } + + return 0.0; + } + + private double calculateOperand(Map data, String operationMode, + Map sessionData, BotUser botUser) { + if ("STATIC".equalsIgnoreCase(operationMode)) { + Object rawStatic = data.get("staticValue"); + if (rawStatic != null) { + String resolved = placeholderResolver.resolveValue(rawStatic.toString(), sessionData, botUser); + try { + return Double.parseDouble(resolved.trim()); + } catch (NumberFormatException e) { + log.warn("Failed to parse staticValue '{}' as number: {}", resolved, e.getMessage()); + } + } + return 1.0; + } + + double min = parseDouble(data.get("randomMin"), 1.0); + double max = parseDouble(data.get("randomMax"), 10.0); + double step = parseDouble(data.get("randomStep"), 1.0); + + if (max < min) { + double tmp = min; + min = max; + max = tmp; + } + if (step <= 0) { + step = 1.0; + } + + int stepsCount = (int) Math.floor((max - min) / step); + if (stepsCount <= 0) { + return min; + } + + int chosenStep = random.nextInt(stepsCount + 1); + return min + (chosenStep * step); + } + + private double parseDouble(Object obj, double fallback) { + if (obj == null) return fallback; + if (obj instanceof Number num) return num.doubleValue(); + try { + return Double.parseDouble(obj.toString().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private String formatNumber(double val) { + if (val == Math.floor(val) && !Double.isInfinite(val)) { + return String.valueOf((long) val); + } + return String.format(Locale.US, "%.2f", val); + } + + private String getDefaultTarget(List edges, String nodeId) { + return edges.stream() + .filter(e -> e.source().equals(nodeId)) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/MessageNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/MessageNodeExecutor.java index 424cb0a8..dc683ced 100644 --- a/backend/src/main/java/com/launchly/bot/engine/executor/MessageNodeExecutor.java +++ b/backend/src/main/java/com/launchly/bot/engine/executor/MessageNodeExecutor.java @@ -1,51 +1,84 @@ package com.launchly.bot.engine.executor; +import com.launchly.bot.engine.executor.block.AudioMessageBlockHandler; +import com.launchly.bot.engine.executor.block.DataCollectionMessageBlockHandler; +import com.launchly.bot.engine.executor.block.DelayMessageBlockHandler; +import com.launchly.bot.engine.executor.block.FileMessageBlockHandler; +import com.launchly.bot.engine.executor.block.ImageMessageBlockHandler; +import com.launchly.bot.engine.executor.block.MessageBlockContext; +import com.launchly.bot.engine.executor.block.MessageBlockHandler; +import com.launchly.bot.engine.executor.block.MessageBlockHelper; +import com.launchly.bot.engine.executor.block.MessageBlockResult; +import com.launchly.bot.engine.executor.block.TextMessageBlockHandler; +import com.launchly.bot.engine.executor.block.VideoMessageBlockHandler; import com.launchly.bot.engine.model.FlowEdge; import com.launchly.bot.engine.model.FlowNode; import com.launchly.bot.entity.BotUser; import com.launchly.bot.entity.NodeType; +import com.launchly.bot.service.BotDialogStateService; +import com.launchly.common.utils.SanitizationUtil; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.methods.send.SendMessage; import org.telegram.telegrambots.meta.api.methods.send.SendPhoto; -import org.telegram.telegrambots.meta.api.methods.send.SendDocument; -import org.telegram.telegrambots.meta.api.methods.send.SendAudio; -import org.telegram.telegrambots.meta.api.methods.send.SendVideo; import org.telegram.telegrambots.meta.api.objects.InputFile; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; -import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; -import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; import org.telegram.telegrambots.meta.exceptions.TelegramApiException; import org.telegram.telegrambots.meta.generics.TelegramClient; -import com.launchly.common.utils.SanitizationUtil; -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URL; -import java.net.URLConnection; +import tools.jackson.databind.ObjectMapper; +import java.time.Duration; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import com.launchly.bot.service.BotDialogStateService; -import lombok.RequiredArgsConstructor; -import tools.jackson.databind.ObjectMapper; -import org.springframework.data.redis.core.StringRedisTemplate; -import com.launchly.bot.engine.model.DataCollectionState; @Slf4j @Component -@RequiredArgsConstructor public class MessageNodeExecutor implements NodeExecutor { - private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{([^}]+)\\}\\}"); - private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]]+)\\]\\(([^\\s)]+)\\)"); - private final BotDialogStateService stateService; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; + private final MessageBlockHelper helper; + private final Map handlerMap; + + @Autowired + public MessageNodeExecutor(BotDialogStateService stateService, + StringRedisTemplate redisTemplate, + ObjectMapper objectMapper, + MessageBlockHelper helper, + List handlers) { + this.stateService = stateService; + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.helper = helper; + this.handlerMap = new HashMap<>(); + for (MessageBlockHandler handler : handlers) { + this.handlerMap.put(handler.getSupportedType(), handler); + } + } + + public MessageNodeExecutor(BotDialogStateService stateService, + StringRedisTemplate redisTemplate, + ObjectMapper objectMapper) { + this(stateService, redisTemplate, objectMapper, new MessageBlockHelper(objectMapper), createDefaultHandlers(redisTemplate, objectMapper)); + } + + private static List createDefaultHandlers(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { + MessageBlockHelper helper = new MessageBlockHelper(objectMapper); + return List.of( + new TextMessageBlockHandler(helper), + new ImageMessageBlockHandler(helper), + new DelayMessageBlockHandler(), + new DataCollectionMessageBlockHandler(helper, redisTemplate, objectMapper), + new FileMessageBlockHandler(helper), + new AudioMessageBlockHandler(helper), + new VideoMessageBlockHandler(helper) + ); + } @Override public NodeType getType() { @@ -64,7 +97,6 @@ public String execute(FlowNode node, List edges, BotUser botUser, if (data != null && data.get("blocks") instanceof List) { blocks = (List>) data.get("blocks"); } - String chatId = botUser.getTelegramId().toString(); if (update != null && update.hasCallbackQuery()) { String callbackData = update.getCallbackQuery().getData(); @@ -86,8 +118,7 @@ public String execute(FlowNode node, List edges, BotUser botUser, if (!allButtons.isEmpty()) { for (Object btnObj : allButtons) { - if (btnObj instanceof Map) { - Map btn = (Map) btnObj; + if (btnObj instanceof Map btn) { Object valObj = btn.get("value"); String value = valObj instanceof String ? (String) valObj : ""; if (callbackData.equals(value)) { @@ -133,324 +164,187 @@ public String execute(FlowNode node, List edges, BotUser botUser, } } - if (blocks != null && !blocks.isEmpty()) { - Long botId = botUser.getBot() != null ? botUser.getBot().getId() : null; - Map sessionData = botId != null ? stateService.getSessionData(botId, botUser.getTelegramId()) : Map.of(); - int blockIdx = 0; - for (Map block : blocks) { - String type = (String) block.get("type"); - if (type == null) { - blockIdx++; - continue; - } + List targetChatIds = resolveTargetChatIds(update, botUser, node); + if (targetChatIds.isEmpty()) { + return null; + } - if ("text".equals(type)) { - String blockText = (String) block.getOrDefault("text", ""); - if (blockText == null || blockText.trim().isEmpty()) { - blockIdx++; + for (String chatId : targetChatIds) { + if (blocks != null && !blocks.isEmpty()) { + Long botId = botUser.getBot() != null ? botUser.getBot().getId() : null; + Map sessionData = botId != null ? stateService.getSessionData(botId, botUser.getTelegramId()) : Map.of(); + for (int i = 0; i < blocks.size(); i++) { + Map block = blocks.get(i); + String type = (String) block.get("type"); + if (type == null) { continue; } - String resolvedText = resolvePlaceholders(blockText, sessionData, botUser); - String escapedText = escapeHtml(resolvedText); - String htmlText = convertMarkdownLinksToHtml(escapedText); + + if ("image".equals(type) && i + 1 < blocks.size()) { + Map nextBlock = blocks.get(i + 1); + if ("text".equals(nextBlock.get("type"))) { + String textContent = (String) nextBlock.getOrDefault("text", ""); + if (textContent != null && !textContent.trim().isEmpty()) { + String resolvedText = helper.resolvePlaceholders(textContent, sessionData, botUser); + String escapedText = helper.escapeHtml(resolvedText); + String htmlText = helper.convertMarkdownLinksToHtml(escapedText); + if (htmlText.length() <= 1024) { + List combinedButtons = (List) nextBlock.get("buttons"); + if ((i + 1 == lastSendableIdx || i == lastSendableIdx) && menuButtons != null && !menuButtons.isEmpty()) { + combinedButtons = menuButtons; + } else if ((combinedButtons == null || combinedButtons.isEmpty()) && block.get("buttons") instanceof List imgBtns && !imgBtns.isEmpty()) { + combinedButtons = imgBtns; + } + InlineKeyboardMarkup combinedMarkup = helper.buildMarkup(combinedButtons); + + Map mergedBlock = new HashMap<>(block); + mergedBlock.put("caption", htmlText); + + MessageBlockContext context = new MessageBlockContext( + mergedBlock, node, botUser, chatId, sessionData, combinedMarkup, client, i, lastSendableIdx + ); + MessageBlockHandler handler = handlerMap.get("image"); + if (handler != null) { + MessageBlockResult result = handler.handle(context); + if (result.haltFlow()) { + return null; + } + if (result.hasButtons()) { + hasButtons = true; + } + } + i++; + continue; + } + } + } + } + + if ("text".equals(type) && i + 1 < blocks.size()) { + Map nextBlock = blocks.get(i + 1); + if ("image".equals(nextBlock.get("type"))) { + String textContent = (String) block.getOrDefault("text", ""); + if (textContent != null && !textContent.trim().isEmpty()) { + String resolvedText = helper.resolvePlaceholders(textContent, sessionData, botUser); + String escapedText = helper.escapeHtml(resolvedText); + String htmlText = helper.convertMarkdownLinksToHtml(escapedText); + if (htmlText.length() <= 1024) { + List combinedButtons = (List) block.get("buttons"); + if ((i + 1 == lastSendableIdx || i == lastSendableIdx) && menuButtons != null && !menuButtons.isEmpty()) { + combinedButtons = menuButtons; + } else if ((combinedButtons == null || combinedButtons.isEmpty()) && nextBlock.get("buttons") instanceof List imgBtns && !imgBtns.isEmpty()) { + combinedButtons = imgBtns; + } + InlineKeyboardMarkup combinedMarkup = helper.buildMarkup(combinedButtons); + + Map mergedBlock = new HashMap<>(nextBlock); + mergedBlock.put("caption", htmlText); + + MessageBlockContext context = new MessageBlockContext( + mergedBlock, node, botUser, chatId, sessionData, combinedMarkup, client, i + 1, lastSendableIdx + ); + MessageBlockHandler handler = handlerMap.get("image"); + if (handler != null) { + MessageBlockResult result = handler.handle(context); + if (result.haltFlow()) { + return null; + } + if (result.hasButtons()) { + hasButtons = true; + } + } + i++; + continue; + } + } + } + } List blockButtons = (List) block.get("buttons"); - if (blockIdx == lastSendableIdx && menuButtons != null && !menuButtons.isEmpty()) { + if (i == lastSendableIdx && menuButtons != null && !menuButtons.isEmpty()) { blockButtons = menuButtons; } - InlineKeyboardMarkup markup = buildMarkup(blockButtons); - if (markup != null) hasButtons = true; + InlineKeyboardMarkup markup = helper.buildMarkup(blockButtons); + + MessageBlockHandler handler = handlerMap.get(type); + if (handler != null) { + MessageBlockContext context = new MessageBlockContext( + block, node, botUser, chatId, sessionData, markup, client, i, lastSendableIdx + ); + MessageBlockResult result = handler.handle(context); + if (result.haltFlow()) { + return null; + } + if (result.hasButtons()) { + hasButtons = true; + } + } else if (!"telegram_menu".equals(type)) { + log.warn("Unknown message block type '{}' in node {}", type, node.id()); + } + } + if (lastSendableIdx == -1 && menuButtons != null && !menuButtons.isEmpty()) { + String sanitized = SanitizationUtil.sanitizeForTelegram("..."); + InlineKeyboardMarkup markup = helper.buildMarkup(menuButtons); + if (markup != null) hasButtons = true; try { SendMessage message = SendMessage.builder() .chatId(chatId) - .text(htmlText) - .parseMode("HTML") + .text(sanitized) .replyMarkup(markup) .build(); client.execute(message); } catch (TelegramApiException e) { - log.error("Failed to send text block in node {}: {}", node.id(), e.getMessage()); + log.error("Failed to send fallback block for telegram_menu: {}", e.getMessage()); } + } + } else { + String text = data != null ? (String) data.get("text") : null; + String imageUrl = data != null ? (String) data.get("imageUrl") : null; + List buttonsList = data != null ? (List) data.get("buttons") : null; - } else if ("image".equals(type)) { - String blockImageUrl = (String) block.get("imageUrl"); - if (blockImageUrl == null || blockImageUrl.trim().isEmpty()) { - blockIdx++; - continue; - } + boolean hasText = text != null && !text.trim().isEmpty(); + boolean hasImage = imageUrl != null && !imageUrl.trim().isEmpty(); + InlineKeyboardMarkup markup = helper.buildMarkup(buttonsList); + if (markup != null) hasButtons = true; - List blockButtons = (List) block.get("buttons"); - if (blockIdx == lastSendableIdx && menuButtons != null && !menuButtons.isEmpty()) { - blockButtons = menuButtons; - } - InlineKeyboardMarkup markup = buildMarkup(blockButtons); - if (markup != null) hasButtons = true; + Long botId = botUser.getBot() != null ? botUser.getBot().getId() : null; + Map sessionData = botId != null ? stateService.getSessionData(botId, botUser.getTelegramId()) : Map.of(); + if (hasImage) { + String resolvedImageUrl = helper.resolvePlaceholders(imageUrl, sessionData, botUser); + String resolvedText = hasText ? helper.resolvePlaceholders(text, sessionData, botUser) : ""; + String sanitizedText = !resolvedText.isEmpty() ? SanitizationUtil.sanitizeForTelegram(resolvedText) : ""; try { SendPhoto sendPhoto = SendPhoto.builder() .chatId(chatId) - .photo(new InputFile(blockImageUrl)) + .photo(new InputFile(resolvedImageUrl)) + .caption(sanitizedText) .replyMarkup(markup) .build(); client.execute(sendPhoto); } catch (TelegramApiException e) { - log.error("Failed to send image block in node {}: {}", node.id(), e.getMessage()); + log.error("Failed to send photo for node {}: {}", node.id(), e.getMessage()); } - - } else if ("delay".equals(type)) { - int delaySeconds = 3; - Object delayObj = block.get("delaySeconds"); - if (delayObj instanceof Number) { - delaySeconds = ((Number) delayObj).intValue(); - } else if (delayObj instanceof String) { - try { - delaySeconds = Integer.parseInt((String) delayObj); - } catch (NumberFormatException e) { - log.warn("Invalid delaySeconds string '{}' in node {}, fallback to 3s", delayObj, node.id()); - } - - } - + } else if (hasText || markup != null) { + String resolvedText = hasText ? helper.resolvePlaceholders(text, sessionData, botUser) : "..."; + String escapedText = helper.escapeHtml(resolvedText); + String htmlText = helper.convertMarkdownLinksToHtml(escapedText); try { - Thread.sleep(delaySeconds * 1000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Delay interrupted in node {}", node.id()); - } - - } else if ("data_collection".equals(type)) { - String blockText = (String) block.getOrDefault("text", ""); - if (blockText != null && !blockText.trim().isEmpty()) { - String resolvedText = resolvePlaceholders(blockText, sessionData, botUser); - String escapedText = escapeHtml(resolvedText); - String htmlText = convertMarkdownLinksToHtml(escapedText); - - try { - SendMessage message = SendMessage.builder() - .chatId(chatId) - .text(htmlText) - .parseMode("HTML") - .build(); - client.execute(message); - } catch (TelegramApiException e) { - log.error("Failed to send data collection question in node {}: {}", node.id(), e.getMessage()); - } - } - - try { - String replyType = (String) block.getOrDefault("replyType", "Text"); - String variableName = (String) block.getOrDefault("variableName", ""); - Object expObj = block.get("expirationMinutes"); - int expirationMinutes = expObj instanceof Number ? ((Number) expObj).intValue() : 30; - Object retryObj = block.get("retryCount"); - int retryCount = retryObj instanceof Number ? ((Number) retryObj).intValue() : 3; - - DataCollectionState state = DataCollectionState.builder() - .nodeId(node.id()) - .blockId((String) block.get("id")) - .replyType(replyType) - .saveToField(variableName) - .retryCount(retryCount) - .expiresAt(System.currentTimeMillis() + (expirationMinutes * 60 * 1000L)) + SendMessage message = SendMessage.builder() + .chatId(chatId) + .text(htmlText) + .parseMode("HTML") + .replyMarkup(markup) .build(); - - String dcKey = "launchly:bot:data_collection:" + botUser.getBot().getId() + ":" + botUser.getTelegramId(); - redisTemplate.opsForValue().set(dcKey, objectMapper.writeValueAsString(state)); - } catch (Exception e) { - log.error("Failed to save data collection state: {}", e.getMessage(), e); - } - - return null; - } else if ("file".equals(type)) { - String blockFileUrl = (String) block.get("fileUrl"); - if (blockFileUrl == null || blockFileUrl.trim().isEmpty()) { - blockIdx++; - continue; - } - - List blockButtons = (List) block.get("buttons"); - if (blockIdx == lastSendableIdx && menuButtons != null && !menuButtons.isEmpty()) { - blockButtons = menuButtons; - } - InlineKeyboardMarkup markup = buildMarkup(blockButtons); - if (markup != null) hasButtons = true; - - boolean isHttp = blockFileUrl.startsWith("http://") || blockFileUrl.startsWith("https://"); - if (isHttp) { - String fileName = (String) block.get("fileName"); - if (fileName == null || fileName.trim().isEmpty()) { - fileName = extractFileName(blockFileUrl); - } - try (InputStream stream = openUrlStream(blockFileUrl)) { - SendDocument sendDocument = SendDocument.builder() - .chatId(chatId) - .document(new InputFile(stream, fileName)) - .replyMarkup(markup) - .build(); - client.execute(sendDocument); - } catch (Exception e) { - log.error("Failed to send file stream in node {}: {}", node.id(), e.getMessage()); - } - } else { - try { - SendDocument sendDocument = SendDocument.builder() - .chatId(chatId) - .document(new InputFile(blockFileUrl)) - .replyMarkup(markup) - .build(); - client.execute(sendDocument); - } catch (TelegramApiException e) { - log.error("Failed to send file block in node {}: {}", node.id(), e.getMessage()); - } - } - } else if ("audio".equals(type)) { - String blockAudioUrl = (String) block.get("audioUrl"); - if (blockAudioUrl == null || blockAudioUrl.trim().isEmpty()) { - blockIdx++; - continue; - } - - List blockButtons = (List) block.get("buttons"); - if (blockIdx == lastSendableIdx && menuButtons != null && !menuButtons.isEmpty()) { - blockButtons = menuButtons; - } - InlineKeyboardMarkup markup = buildMarkup(blockButtons); - if (markup != null) hasButtons = true; - - boolean isHttp = blockAudioUrl.startsWith("http://") || blockAudioUrl.startsWith("https://"); - if (isHttp) { - String fileName = (String) block.get("fileName"); - if (fileName == null || fileName.trim().isEmpty()) { - fileName = extractFileName(blockAudioUrl); - if (!fileName.contains(".")) { - fileName += ".mp3"; - } - } - try (InputStream stream = openUrlStream(blockAudioUrl)) { - SendAudio sendAudio = SendAudio.builder() - .chatId(chatId) - .audio(new InputFile(stream, fileName)) - .replyMarkup(markup) - .build(); - client.execute(sendAudio); - } catch (Exception e) { - log.error("Failed to send audio stream in node {}: {}", node.id(), e.getMessage()); - } - } else { - try { - SendAudio sendAudio = SendAudio.builder() - .chatId(chatId) - .audio(new InputFile(blockAudioUrl)) - .replyMarkup(markup) - .build(); - client.execute(sendAudio); - } catch (TelegramApiException e) { - log.error("Failed to send audio block in node {}: {}", node.id(), e.getMessage()); - } - } - } else if ("video".equals(type)) { - String blockVideoUrl = (String) block.get("videoUrl"); - if (blockVideoUrl == null || blockVideoUrl.trim().isEmpty()) { - blockIdx++; - continue; - } - - List blockButtons = (List) block.get("buttons"); - if (blockIdx == lastSendableIdx && menuButtons != null && !menuButtons.isEmpty()) { - blockButtons = menuButtons; - } - InlineKeyboardMarkup markup = buildMarkup(blockButtons); - if (markup != null) hasButtons = true; - - boolean isHttp = blockVideoUrl.startsWith("http://") || blockVideoUrl.startsWith("https://"); - if (isHttp) { - String fileName = (String) block.get("fileName"); - if (fileName == null || fileName.trim().isEmpty()) { - fileName = extractFileName(blockVideoUrl); - if (!fileName.contains(".")) { - fileName += ".mp4"; - } - } - try (InputStream stream = openUrlStream(blockVideoUrl)) { - SendVideo sendVideo = SendVideo.builder() - .chatId(chatId) - .video(new InputFile(stream, fileName)) - .replyMarkup(markup) - .build(); - client.execute(sendVideo); - } catch (Exception e) { - log.error("Failed to send video stream in node {}: {}", node.id(), e.getMessage()); - } - } else { - try { - SendVideo sendVideo = SendVideo.builder() - .chatId(chatId) - .video(new InputFile(blockVideoUrl)) - .replyMarkup(markup) - .build(); - client.execute(sendVideo); - } catch (TelegramApiException e) { - log.error("Failed to send video block in node {}: {}", node.id(), e.getMessage()); - } + client.execute(message); + } catch (TelegramApiException e) { + log.error("Failed to send legacy flat message for node {}: {}", node.id(), e.getMessage()); } - } - blockIdx++; - } - - if (lastSendableIdx == -1 && menuButtons != null && !menuButtons.isEmpty()) { - String sanitized = SanitizationUtil.sanitizeForTelegram("..."); - InlineKeyboardMarkup markup = buildMarkup(menuButtons); - if (markup != null) hasButtons = true; - try { - SendMessage message = SendMessage.builder() - .chatId(chatId) - .text(sanitized) - .replyMarkup(markup) - .build(); - client.execute(message); - } catch (TelegramApiException e) { - log.error("Failed to send fallback block for telegram_menu: {}", e.getMessage()); + } else { + log.debug("Message node {} is empty (no text/image/buttons), skipping sending.", node.id()); } } - } else { - String text = data != null ? (String) data.get("text") : null; - String imageUrl = data != null ? (String) data.get("imageUrl") : null; - List buttonsList = data != null ? (List) data.get("buttons") : null; - - boolean hasText = text != null && !text.trim().isEmpty(); - boolean hasImage = imageUrl != null && !imageUrl.trim().isEmpty(); - InlineKeyboardMarkup markup = buildMarkup(buttonsList); - if (markup != null) hasButtons = true; - - if (hasImage) { - String sanitizedText = hasText ? SanitizationUtil.sanitizeForTelegram(text) : ""; - try { - SendPhoto sendPhoto = SendPhoto.builder() - .chatId(chatId) - .photo(new InputFile(imageUrl)) - .caption(sanitizedText) - .replyMarkup(markup) - .build(); - client.execute(sendPhoto); - } catch (TelegramApiException e) { - log.error("Failed to send photo for node {}: {}", node.id(), e.getMessage()); - } - } else if (hasText || markup != null) { - String sanitizedText = hasText ? SanitizationUtil.sanitizeForTelegram(text) : "..."; - try { - SendMessage message = SendMessage.builder() - .chatId(chatId) - .text(sanitizedText) - .replyMarkup(markup) - .build(); - client.execute(message); - } catch (TelegramApiException e) { - log.error("Failed to send legacy flat message for node {}: {}", node.id(), e.getMessage()); - } - } else { - log.debug("Message node {} is empty (no text/image/buttons), skipping sending.", node.id()); - } } if (hasButtons) { @@ -464,162 +358,61 @@ public String execute(FlowNode node, List edges, BotUser botUser, .orElse(null); } - private InlineKeyboardMarkup buildMarkup(List buttonsList) { - if (buttonsList == null || buttonsList.isEmpty()) { - return null; - } - List rows = new ArrayList<>(); - InlineKeyboardRow currentRow = null; - String lastRowStr = null; - - for (Object btnObj : buttonsList) { - if (btnObj instanceof Map) { - Map btn = (Map) btnObj; - Object labelObj = btn.get("label"); - String label = labelObj instanceof String ? (String) labelObj : "Button"; - Object valueObj = btn.get("value"); - String value = valueObj instanceof String ? (String) valueObj : label; - Object rowObj = btn.get("row"); - String rowStr = rowObj != null ? rowObj.toString() : null; - - InlineKeyboardButton button = InlineKeyboardButton.builder() - .text(label) - .callbackData(value) - .build(); - - if (rowStr != null && !rowStr.trim().isEmpty()) { - if (currentRow == null || !rowStr.equals(lastRowStr)) { - currentRow = new InlineKeyboardRow(); - rows.add(currentRow); - lastRowStr = rowStr; - } - currentRow.add(button); - } else { - currentRow = new InlineKeyboardRow(button); - rows.add(currentRow); - lastRowStr = null; - } + private String resolveChatId(Update update, BotUser botUser) { + if (update != null) { + if (update.hasMessage() && update.getMessage().getChat() != null) { + return update.getMessage().getChatId().toString(); + } + if (update.hasCallbackQuery() && update.getCallbackQuery().getMessage() != null && update.getCallbackQuery().getMessage().getChat() != null) { + return update.getCallbackQuery().getMessage().getChatId().toString(); + } + if (update.hasChannelPost() && update.getChannelPost().getChat() != null) { + return update.getChannelPost().getChatId().toString(); + } + if (update.hasEditedMessage() && update.getEditedMessage().getChat() != null) { + return update.getEditedMessage().getChatId().toString(); + } + if (update.hasEditedChannelPost() && update.getEditedChannelPost().getChat() != null) { + return update.getEditedChannelPost().getChatId().toString(); } } - return rows.isEmpty() ? null : InlineKeyboardMarkup.builder().keyboard(rows).build(); + return botUser != null && botUser.getTelegramId() != null ? botUser.getTelegramId().toString() : null; } - private String extractFileName(String url) { - if (url == null || url.trim().isEmpty()) { - return "file"; - } - int lastSlash = url.lastIndexOf('/'); - if (lastSlash != -1 && lastSlash < url.length() - 1) { - String candidate = url.substring(lastSlash + 1); - int questionMark = candidate.indexOf('?'); - if (questionMark != -1) { - candidate = candidate.substring(0, questionMark); - } - return candidate; + @SuppressWarnings("unchecked") + private List resolveTargetChatIds(Update update, BotUser botUser, FlowNode node) { + if (update != null) { + String singleId = resolveChatId(update, botUser); + return singleId != null ? List.of(singleId) : List.of(); } - return "file"; - } - private InputStream openUrlStream(String urlString) throws IOException { - URL url = URI.create(urlString).toURL(); - URLConnection connection = url.openConnection(); - connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - return connection.getInputStream(); - } + List chatIds = new ArrayList<>(); + if (botUser != null && botUser.getTelegramId() != null) { + chatIds.add(botUser.getTelegramId().toString()); + } - private String resolvePlaceholders(String text, Map variables, BotUser botUser) { - if (text == null) return ""; - String result = text; - - Matcher matcher = PLACEHOLDER_PATTERN.matcher(result); - StringBuffer sb = new StringBuffer(); - while (matcher.find()) { - String rawName = matcher.group(1).trim(); - String replacement = ""; - if (rawName.equalsIgnoreCase("first_name") || rawName.equalsIgnoreCase("First Name")) { - replacement = botUser.getFirstName() != null ? botUser.getFirstName() : ""; - } else if (rawName.equalsIgnoreCase("last_name") || rawName.equalsIgnoreCase("Last Name")) { - replacement = botUser.getLastName() != null ? botUser.getLastName() : ""; - } else if (rawName.equalsIgnoreCase("username") || rawName.equalsIgnoreCase("telegram_username") || rawName.equalsIgnoreCase("Telegram Username")) { - String username = botUser.getUsername(); - if (username != null && !username.trim().isEmpty()) { - replacement = username.startsWith("@") ? username : "@" + username; - } else { - replacement = ""; - } - } else if (rawName.equalsIgnoreCase("telegram_user_id") || rawName.equalsIgnoreCase("Telegram User ID")) { - replacement = botUser.getTelegramId() != null ? String.valueOf(botUser.getTelegramId()) : ""; - } else if (rawName.equalsIgnoreCase("contact_id") || rawName.equalsIgnoreCase("Contact Id")) { - replacement = botUser.getId() != null ? String.valueOf(botUser.getId()) : ""; - } else if (rawName.equalsIgnoreCase("phone") || rawName.equalsIgnoreCase("Phone")) { - replacement = variables.getOrDefault("phone", ""); - } else if (rawName.equalsIgnoreCase("email") || rawName.equalsIgnoreCase("Email")) { - replacement = variables.getOrDefault("email", ""); - } else if (rawName.equalsIgnoreCase("subscribed") || rawName.equalsIgnoreCase("Subscribed")) { - replacement = variables.getOrDefault("telegram_opt_in", "false"); - } else if (rawName.equalsIgnoreCase("last_reply_type") || rawName.equalsIgnoreCase("Last Reply Type")) { - replacement = variables.getOrDefault("last_reply_type", "text"); - } else { - boolean found = false; - for (Map.Entry entry : variables.entrySet()) { - if (entry.getKey().equalsIgnoreCase(rawName)) { - replacement = entry.getValue() != null ? entry.getValue() : ""; - found = true; - break; - } - } - if (!found) { - try { - if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { - ObjectMapper objectMapper = new ObjectMapper(); - Map metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); - Map customFields = (Map) metaMap.get("customFields"); - if (customFields != null) { - for (Map.Entry entry : customFields.entrySet()) { - if (entry.getKey().equalsIgnoreCase(rawName)) { - replacement = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; - found = true; - break; - } + if (botUser != null && botUser.getMetadata() != null && !botUser.getMetadata().isBlank()) { + try { + Map meta = objectMapper.readValue(botUser.getMetadata(), Map.class); + Object ccf = meta.get("chatCustomFields"); + if (ccf instanceof Map ccfMap) { + for (Object k : ccfMap.keySet()) { + if (k != null) { + String groupId = k.toString().trim(); + if (groupId.startsWith("-")) { + Long botId = botUser.getBot() != null ? botUser.getBot().getId() : 0L; + String groupLockKey = "flow:scheduler:group_msg:" + botId + ":" + node.id() + ":" + groupId; + Boolean canSend = redisTemplate.opsForValue().setIfAbsent(groupLockKey, "1", Duration.ofSeconds(60)); + if (Boolean.TRUE.equals(canSend)) { + chatIds.add(groupId); } } } - } catch (Exception e) { - log.warn("Failed to parse customFields from botUser metadata: {}", e.getMessage()); } - - } - if (!found) { - replacement = matcher.group(0); } - } - matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } catch (Exception ignored) {} } - matcher.appendTail(sb); - return sb.toString(); - } - private String escapeHtml(String text) { - if (text == null) return ""; - return text.replace("&", "&") - .replace("<", "<") - .replace(">", ">"); - } - - private String convertMarkdownLinksToHtml(String text) { - if (text == null) return ""; - Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(text); - StringBuffer sb = new StringBuffer(); - while (matcher.find()) { - String linkText = matcher.group(1); - String url = matcher.group(2).trim(); - if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("tg://")) { - url = "https://" + url; - } - String htmlLink = "" + linkText + ""; - matcher.appendReplacement(sb, Matcher.quoteReplacement(htmlLink)); - } - matcher.appendTail(sb); - return sb.toString(); + return chatIds; } } diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/ModerationNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/ModerationNodeExecutor.java new file mode 100644 index 00000000..eae7c5e1 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/ModerationNodeExecutor.java @@ -0,0 +1,181 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.constant.ModerationConstants; +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.service.BotDialogStateService; +import com.launchly.bot.service.helper.BotModerationHelper; +import com.launchly.common.utils.MessageUtils; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.message.Message; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.*; +import java.util.concurrent.ScheduledExecutorService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ModerationNodeExecutor implements NodeExecutor { + + private static final List PASS_HANDLES = List.of("clean", "passed", "success", "true", "yes"); + private static final List FAIL_HANDLES = List.of("violated", "blocked", "failed", "false", "no"); + + private final BotDialogStateService stateService; + private final MessageUtils messageUtils; + private final ScheduledExecutorService scheduledExecutor; + + @Override + public NodeType getType() { + return NodeType.MODERATION; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, Update update, TelegramClient client) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + Map data = node.data() != null ? node.data() : Collections.emptyMap(); + + boolean antiForward = Boolean.TRUE.equals(data.get("antiForwardEnabled")); + boolean antiLink = Boolean.TRUE.equals(data.get("antiLinkEnabled")); + String allowedLinks = extractJoinedString(data.get("whitelistedDomains"), (String) data.getOrDefault("allowedLinks", "")); + String stopWords = extractJoinedString(data.get("stopWords"), ""); + boolean defaultProfanity = data.get("defaultProfanityFilter") == null || Boolean.TRUE.equals(data.get("defaultProfanityFilter")) || Boolean.TRUE.equals(data.get("filterProfanity")); + String mediaModeStr = (String) data.getOrDefault("mediaMode", "ALL"); + String sanctionStr = (String) data.getOrDefault("actionOnViolation", "DELETE_AND_WARN"); + String warningTemplate = (String) data.getOrDefault("warningTemplate", ModerationConstants.DEFAULT_WARNING_TEMPLATE); + int warnTtl = parseInteger(data.get("warnAutoDeleteSeconds"), parseInteger(data.get("warnTtlSeconds"), ModerationConstants.DEFAULT_WARN_AUTO_DELETE_SECONDS)); + int muteDurationMinutes = parseInteger(data.get("muteDurationMinutes"), ModerationConstants.DEFAULT_MUTE_DURATION_MINUTES); + + String passVar = (String) data.getOrDefault("passVariable", ModerationConstants.DEFAULT_PASS_VARIABLE); + String reasonVar = (String) data.getOrDefault("violationReasonVariable", ModerationConstants.DEFAULT_REASON_VARIABLE); + + List reasons = new ArrayList<>(); + + if (update != null && update.hasMessage()) { + Message message = update.getMessage(); + + if (antiForward && BotModerationHelper.isForwarded(message)) { + reasons.add(ModerationConstants.CODE_ANTI_FORWARD); + } + + boolean hasMedia = BotModerationHelper.hasMediaContent(message); + if ("TEXT_ONLY".equalsIgnoreCase(mediaModeStr) && hasMedia) { + reasons.add(ModerationConstants.CODE_TEXT_ONLY); + } else if ("MEDIA_ONLY".equalsIgnoreCase(mediaModeStr) && !hasMedia) { + reasons.add(ModerationConstants.CODE_MEDIA_ONLY); + } + + String text = BotModerationHelper.extractMessageText(message); + if (antiLink && text != null) { + List links = BotModerationHelper.extractUrls(text); + List whitelist = BotModerationHelper.parseList(allowedLinks); + for (String link : links) { + if (!BotModerationHelper.isAllowedLink(link, whitelist)) { + reasons.add(ModerationConstants.CODE_ANTI_LINK_PREFIX + link); + break; + } + } + } + + if (text != null) { + String matched = BotModerationHelper.findMatchedStopWord(text, defaultProfanity, stopWords); + if (matched != null) { + reasons.add(ModerationConstants.CODE_STOP_WORD_PREFIX + matched); + } + } + + if (!reasons.isEmpty() && client != null && !"NONE".equalsIgnoreCase(sanctionStr)) { + executeSanction(client, message, sanctionStr, warningTemplate, warnTtl, muteDurationMinutes); + } + } + + boolean passed = reasons.isEmpty(); + + stateService.setSessionData(botId, telegramUserId, passVar, String.valueOf(passed)); + stateService.setSessionData(botId, telegramUserId, reasonVar, String.join(", ", reasons)); + + return resolveNextTarget(edges, node.id(), passed); + } + + private void executeSanction(TelegramClient client, + Message message, + String sanction, + String warningTemplate, + int warnTtl, + int muteDurationMinutes) { + Long chatId = message.getChatId(); + Integer messageId = message.getMessageId(); + User from = message.getFrom(); + Long userId = from != null ? from.getId() : null; + + BotModerationHelper.deleteMessageSafe(client, chatId, messageId); + + if ("DELETE_AND_WARN".equalsIgnoreCase(sanction) + || "DELETE_AND_MUTE".equalsIgnoreCase(sanction) + || "DELETE_AND_KICK".equalsIgnoreCase(sanction)) { + BotModerationHelper.sendSelfDestructWarning(client, scheduledExecutor, chatId, from, warningTemplate, warnTtl, messageUtils); + } + + if (userId != null) { + if ("DELETE_AND_MUTE".equalsIgnoreCase(sanction)) { + int durationSeconds = muteDurationMinutes > 0 + ? muteDurationMinutes * ModerationConstants.SECONDS_PER_MINUTE + : ModerationConstants.DEFAULT_MUTE_DURATION_SECONDS; + BotModerationHelper.muteUser(client, chatId, userId, durationSeconds); + } else if ("DELETE_AND_KICK".equalsIgnoreCase(sanction)) { + BotModerationHelper.kickUser(client, chatId, userId); + } + } + } + + private String extractJoinedString(Object obj, String fallback) { + if (obj instanceof List list) { + return String.join(", ", list.stream().map(Object::toString).toList()); + } else if (obj instanceof String s && !s.isBlank()) { + return s; + } + return fallback; + } + + private int parseInteger(Object value, int defaultValue) { + if (value == null) return defaultValue; + if (value instanceof Number n) return n.intValue(); + try { + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return defaultValue; + } + } + + private String resolveNextTarget(List edges, String nodeId, boolean passed) { + List preferredHandles = passed ? PASS_HANDLES : FAIL_HANDLES; + + for (String handle : preferredHandles) { + String target = findTarget(edges, nodeId, handle); + if (target != null) return target; + } + + return edges.stream() + .filter(e -> e.source().equals(nodeId)) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } + + private String findTarget(List edges, String nodeId, String handleId) { + return edges.stream() + .filter(e -> e.source().equals(nodeId)) + .filter(e -> handleId.equals(e.sourceHandle())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/OrderNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/OrderNodeExecutor.java index f3b7c9ca..b536d14f 100644 --- a/backend/src/main/java/com/launchly/bot/engine/executor/OrderNodeExecutor.java +++ b/backend/src/main/java/com/launchly/bot/engine/executor/OrderNodeExecutor.java @@ -52,6 +52,7 @@ public String execute(FlowNode node, List edges, BotUser botUser, try { totalAmount = new BigDecimal(price); } catch (NumberFormatException e) { + log.warn("Invalid order price '{}', defaulting to zero: {}", price, e.getMessage()); totalAmount = BigDecimal.ZERO; } String currency = data != null ? (String) data.getOrDefault("currency", "UAH") : "UAH"; diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/QueryNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/QueryNodeExecutor.java new file mode 100644 index 00000000..af5cbdac --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/QueryNodeExecutor.java @@ -0,0 +1,482 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.repository.BotUserInteractionRepository; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.BotDialogStateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class QueryNodeExecutor implements NodeExecutor { + + private static final String DEFAULT_OUTPUT_PREFIX = "found_user"; + private static final String SORT_RANDOM = "RANDOM"; + private static final String SORT_NEWEST = "NEWEST"; + private static final String SORT_OLDEST = "OLDEST"; + + private static final String KEY_OUTPUT_PREFIX = "outputPrefix"; + private static final String KEY_SORT_ORDER = "sortOrder"; + private static final String KEY_EXCLUDE_SELF = "excludeSelf"; + private static final String KEY_EXCLUDE_INTERACTIONS = "excludeInteractions"; + private static final String KEY_FILTERS = "filters"; + + private static final String KEY_FIELD = "field"; + private static final String KEY_OPERATOR = "operator"; + private static final String KEY_VALUE = "value"; + + private static final String KEY_CUSTOM_FIELDS = "customFields"; + private static final String KEY_CHAT_CUSTOM_FIELDS = "chatCustomFields"; + + private static final String HANDLE_FOUND = "found"; + private static final String HANDLE_NEXT = "next"; + private static final String HANDLE_NOT_FOUND = "not_found"; + private static final String HANDLE_FALLBACK = "fallback"; + + private static final String FIELD_ID = "id"; + private static final String FIELD_TELEGRAM_ID = "telegram_id"; + private static final String FIELD_TELEGRAM_USER_ID = "telegram_user_id"; + private static final String FIELD_FIRST_NAME = "first_name"; + private static final String FIELD_LAST_NAME = "last_name"; + private static final String FIELD_USERNAME = "username"; + private static final String FIELD_TELEGRAM_USERNAME = "telegram_username"; + private static final String FIELD_PHOTO_URL = "photo_url"; + + private static final String OP_EQUALS = "equals"; + private static final String OP_IS = "is"; + private static final String OP_NOT_EQUALS = "not_equals"; + private static final String OP_IS_NOT = "is_not"; + private static final String OP_CONTAINS = "contains"; + private static final String OP_NOT_CONTAINS = "not_contains"; + private static final String OP_EXISTS = "exists"; + private static final String OP_IS_SET = "is_set"; + private static final String OP_NOT_EXISTS = "not_exists"; + private static final String OP_IS_NOT_SET = "is_not_set"; + private static final String OP_GREATER_THAN = "greater_than"; + private static final String OP_GT = "gt"; + private static final String OP_LESS_THAN = "less_than"; + private static final String OP_LT = "lt"; + private static final String OP_GREATER_THAN_OR_EQUALS = "greater_than_or_equals"; + private static final String OP_GREATER_THAN_OR_EQUAL = "greater_than_or_equal"; + private static final String OP_GTE = "gte"; + private static final String OP_GTE_SYMBOL = ">="; + private static final String OP_LESS_THAN_OR_EQUALS = "less_than_or_equals"; + private static final String OP_LESS_THAN_OR_EQUAL = "less_than_or_equal"; + private static final String OP_LTE = "lte"; + private static final String OP_LTE_SYMBOL = "<="; + + private static final String PREFIX_STRIP_REGEX = "^(?i)(contact|user|customfields|custom_fields|fields|metadata)\\."; + + private final BotUserRepository botUserRepository; + private final BotUserInteractionRepository interactionRepository; + private final BotDialogStateService stateService; + private final ObjectMapper objectMapper; + + @Override + public NodeType getType() { + return NodeType.QUERY; + } + + @Override + @SuppressWarnings("unchecked") + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + Long botId = botUser.getBot().getId(); + Long currentTelegramId = botUser.getTelegramId(); + Map data = node.data(); + + log.info("Executing Query Node {} for bot user {}", node.id(), currentTelegramId); + + String outputPrefix = DEFAULT_OUTPUT_PREFIX; + String sortOrder = SORT_RANDOM; + boolean excludeSelf = true; + List excludeInteractions = new ArrayList<>(); + List> filters = new ArrayList<>(); + + if (data != null) { + if (data.get(KEY_OUTPUT_PREFIX) instanceof String s && !s.trim().isEmpty()) { + outputPrefix = s.trim(); + } + if (data.get(KEY_SORT_ORDER) instanceof String s && !s.trim().isEmpty()) { + sortOrder = s.trim().toUpperCase(); + } + if (data.get(KEY_EXCLUDE_SELF) != null) { + excludeSelf = Boolean.parseBoolean(data.get(KEY_EXCLUDE_SELF).toString()); + } + if (data.get(KEY_EXCLUDE_INTERACTIONS) instanceof List list) { + for (Object item : list) { + if (item != null && !item.toString().trim().isEmpty()) { + excludeInteractions.add(item.toString().trim()); + } + } + } + if (data.get(KEY_FILTERS) instanceof List fList) { + for (Object f : fList) { + if (f instanceof Map fMap) { + filters.add((Map) fMap); + } + } + } + } + + Map currentSessionData = stateService.getSessionData(botId, currentTelegramId); + + Set excludedTelegramIds = new HashSet<>(); + if (excludeSelf) { + excludedTelegramIds.add(currentTelegramId); + } + if (!excludeInteractions.isEmpty()) { + List interactedIds = interactionRepository.findInteractedTargetTelegramIds( + botId, currentTelegramId, excludeInteractions + ); + if (interactedIds != null) { + excludedTelegramIds.addAll(interactedIds); + } + } + + List allBotUsers = botUserRepository.findAllByBotId(botId); + List candidates = new ArrayList<>(); + + for (BotUser candidate : allBotUsers) { + if (candidate.getTelegramId() != null && excludedTelegramIds.contains(candidate.getTelegramId())) { + continue; + } + + Map candidateMetadata = parseMetadata(candidate.getMetadata()); + + if (matchesAllFilters(candidate, candidateMetadata, filters, currentSessionData, botUser)) { + candidates.add(candidate); + } + } + + if (!candidates.isEmpty()) { + BotUser selected; + if (SORT_NEWEST.equals(sortOrder)) { + candidates.sort((a, b) -> { + if (a.getCreatedAt() == null || b.getCreatedAt() == null) return 0; + return b.getCreatedAt().compareTo(a.getCreatedAt()); + }); + selected = candidates.get(0); + } else if (SORT_OLDEST.equals(sortOrder)) { + candidates.sort((a, b) -> { + if (a.getCreatedAt() == null || b.getCreatedAt() == null) return 0; + return a.getCreatedAt().compareTo(b.getCreatedAt()); + }); + selected = candidates.get(0); + } else { + selected = candidates.get(new Random().nextInt(candidates.size())); + } + + populateCandidateSessionVariables(botId, currentTelegramId, outputPrefix, selected); + + String foundTarget = findTarget(edges, node.id(), HANDLE_FOUND); + if (foundTarget != null) { + return foundTarget; + } + return findTarget(edges, node.id(), HANDLE_NEXT); + } else { + String notFoundTarget = findTarget(edges, node.id(), HANDLE_NOT_FOUND); + if (notFoundTarget != null) { + return notFoundTarget; + } + return findTarget(edges, node.id(), HANDLE_FALLBACK); + } + } + + private void populateCandidateSessionVariables(Long botId, Long currentTelegramId, String prefix, BotUser candidate) { + stateService.setSessionData(botId, currentTelegramId, prefix + ".id", String.valueOf(candidate.getId())); + stateService.setSessionData(botId, currentTelegramId, prefix + ".telegram_id", String.valueOf(candidate.getTelegramId())); + stateService.setSessionData(botId, currentTelegramId, prefix + ".first_name", candidate.getFirstName() != null ? candidate.getFirstName() : ""); + stateService.setSessionData(botId, currentTelegramId, prefix + ".last_name", candidate.getLastName() != null ? candidate.getLastName() : ""); + stateService.setSessionData(botId, currentTelegramId, prefix + ".name", candidate.getFirstName() != null ? candidate.getFirstName() : ""); + stateService.setSessionData(botId, currentTelegramId, prefix + ".username", candidate.getUsername() != null ? candidate.getUsername() : ""); + stateService.setSessionData(botId, currentTelegramId, prefix + ".telegram_username", candidate.getUsername() != null ? candidate.getUsername() : ""); + stateService.setSessionData(botId, currentTelegramId, prefix + ".photo_url", candidate.getPhotoUrl() != null ? candidate.getPhotoUrl() : ""); + + stateService.setSessionData(botId, currentTelegramId, "found_id", String.valueOf(candidate.getId())); + stateService.setSessionData(botId, currentTelegramId, "found_telegram_id", String.valueOf(candidate.getTelegramId())); + stateService.setSessionData(botId, currentTelegramId, "found_first_name", candidate.getFirstName() != null ? candidate.getFirstName() : ""); + stateService.setSessionData(botId, currentTelegramId, "found_last_name", candidate.getLastName() != null ? candidate.getLastName() : ""); + stateService.setSessionData(botId, currentTelegramId, "found_username", candidate.getUsername() != null ? candidate.getUsername() : ""); + stateService.setSessionData(botId, currentTelegramId, "found_telegram_username", candidate.getUsername() != null ? candidate.getUsername() : ""); + stateService.setSessionData(botId, currentTelegramId, "found_photo_url", candidate.getPhotoUrl() != null ? candidate.getPhotoUrl() : ""); + + Map candidateMetadata = parseMetadata(candidate.getMetadata()); + for (Map.Entry entry : candidateMetadata.entrySet()) { + if (entry.getValue() != null && !(entry.getValue() instanceof Map)) { + stateService.setSessionData(botId, currentTelegramId, prefix + "." + entry.getKey(), entry.getValue().toString()); + stateService.setSessionData(botId, currentTelegramId, "found_" + entry.getKey(), entry.getValue().toString()); + } + } + + if (candidateMetadata.get(KEY_CUSTOM_FIELDS) instanceof Map cfMap) { + for (Map.Entry entry : cfMap.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + String k = entry.getKey().toString(); + String v = entry.getValue().toString(); + stateService.setSessionData(botId, currentTelegramId, prefix + "." + k, v); + stateService.setSessionData(botId, currentTelegramId, prefix + "_" + k, v); + stateService.setSessionData(botId, currentTelegramId, "found_" + k, v); + } + } + } + } + + private boolean matchesAllFilters(BotUser candidate, Map candidateMetadata, + List> filters, + Map currentSessionData, + BotUser currentUser) { + if (filters == null || filters.isEmpty()) { + return true; + } + + for (Map filter : filters) { + String field = (String) filter.getOrDefault(KEY_FIELD, ""); + String operator = (String) filter.getOrDefault(KEY_OPERATOR, OP_EQUALS); + String rawValue = (String) filter.getOrDefault(KEY_VALUE, ""); + + String expectedValue = resolveValuePlaceholder(rawValue, currentSessionData, currentUser); + String actualValue = extractCandidateValue(field, candidate, candidateMetadata); + + log.debug("Evaluating Query filter: field='{}', actual='{}', operator='{}', rawValue='{}', expected='{}'", + field, actualValue, operator, rawValue, expectedValue); + + if (!evaluateCondition(actualValue, operator, expectedValue)) { + return false; + } + } + return true; + } + + private String extractCandidateValue(String field, BotUser candidate, Map metadata) { + if (field == null || field.trim().isEmpty()) return ""; + String f = field.trim(); + if (f.startsWith("{") && f.endsWith("}")) { + f = f.substring(1, f.length() - 1).trim(); + } + if (f.toLowerCase().startsWith("metadata.")) { + f = f.substring("metadata.".length()); + } + if (f.toLowerCase().startsWith("customfields.") || f.toLowerCase().startsWith("custom_fields.")) { + f = f.substring(f.indexOf('.') + 1); + } + if (f.toLowerCase().startsWith("contact.") || f.toLowerCase().startsWith("user.")) { + f = f.substring(f.indexOf('.') + 1); + } + + switch (f.toLowerCase()) { + case FIELD_ID: + return candidate.getId() != null ? candidate.getId().toString() : ""; + case FIELD_TELEGRAM_ID: + case FIELD_TELEGRAM_USER_ID: + return candidate.getTelegramId() != null ? candidate.getTelegramId().toString() : ""; + case FIELD_FIRST_NAME: + return candidate.getFirstName() != null ? candidate.getFirstName() : ""; + case FIELD_LAST_NAME: + return candidate.getLastName() != null ? candidate.getLastName() : ""; + case FIELD_USERNAME: + case FIELD_TELEGRAM_USERNAME: + return candidate.getUsername() != null ? candidate.getUsername() : ""; + case FIELD_PHOTO_URL: + return candidate.getPhotoUrl() != null ? candidate.getPhotoUrl() : ""; + default: + Object val = metadata.get(f); + if (val == null) { + val = metadata.get(field.trim()); + } + if (val == null && metadata.get(KEY_CUSTOM_FIELDS) instanceof Map cfMap) { + for (Map.Entry entry : cfMap.entrySet()) { + if (String.valueOf(entry.getKey()).equalsIgnoreCase(f) || String.valueOf(entry.getKey()).equalsIgnoreCase(field.trim())) { + val = entry.getValue(); + break; + } + } + } + if (val == null && metadata.get(KEY_CHAT_CUSTOM_FIELDS) instanceof Map chatMap) { + for (Object groupObj : chatMap.values()) { + if (groupObj instanceof Map groupMap) { + for (Map.Entry entry : groupMap.entrySet()) { + if (String.valueOf(entry.getKey()).equalsIgnoreCase(f) || String.valueOf(entry.getKey()).equalsIgnoreCase(field.trim())) { + val = entry.getValue(); + break; + } + } + if (val != null) break; + } + } + } + return val != null ? val.toString().trim() : ""; + } + } + + private String resolveValuePlaceholder(String rawValue, Map currentSessionData, BotUser currentUser) { + if (rawValue == null || rawValue.isEmpty()) return ""; + String trimmed = rawValue.trim(); + String varKey = trimmed; + boolean hasBraces = trimmed.startsWith("{") && trimmed.endsWith("}"); + if (hasBraces) { + varKey = trimmed.substring(1, trimmed.length() - 1).trim(); + } + + String strippedKey = varKey.replaceFirst(PREFIX_STRIP_REGEX, "").trim(); + + if (currentUser != null) { + if (FIELD_FIRST_NAME.equalsIgnoreCase(strippedKey) || "First Name".equalsIgnoreCase(strippedKey)) { + return currentUser.getFirstName() != null ? currentUser.getFirstName() : ""; + } + if (FIELD_LAST_NAME.equalsIgnoreCase(strippedKey) || "Last Name".equalsIgnoreCase(strippedKey)) { + return currentUser.getLastName() != null ? currentUser.getLastName() : ""; + } + if (FIELD_USERNAME.equalsIgnoreCase(strippedKey) || FIELD_TELEGRAM_USERNAME.equalsIgnoreCase(strippedKey)) { + return currentUser.getUsername() != null ? currentUser.getUsername() : ""; + } + if (FIELD_TELEGRAM_ID.equalsIgnoreCase(strippedKey) || FIELD_TELEGRAM_USER_ID.equalsIgnoreCase(strippedKey)) { + return currentUser.getTelegramId() != null ? currentUser.getTelegramId().toString() : ""; + } + if (FIELD_ID.equalsIgnoreCase(strippedKey) || "contact_id".equalsIgnoreCase(strippedKey)) { + return currentUser.getId() != null ? currentUser.getId().toString() : ""; + } + + Map userMeta = parseMetadata(currentUser.getMetadata()); + if (userMeta.containsKey(varKey) && userMeta.get(varKey) != null && !(userMeta.get(varKey) instanceof Map)) { + return userMeta.get(varKey).toString(); + } + if (userMeta.containsKey(strippedKey) && userMeta.get(strippedKey) != null && !(userMeta.get(strippedKey) instanceof Map)) { + return userMeta.get(strippedKey).toString(); + } + if (userMeta.get(KEY_CUSTOM_FIELDS) instanceof Map cfMap) { + for (Map.Entry entry : cfMap.entrySet()) { + if (String.valueOf(entry.getKey()).equalsIgnoreCase(varKey) || String.valueOf(entry.getKey()).equalsIgnoreCase(strippedKey)) { + return entry.getValue() != null ? entry.getValue().toString() : ""; + } + } + } + if (userMeta.get(KEY_CHAT_CUSTOM_FIELDS) instanceof Map chatMap) { + for (Object groupObj : chatMap.values()) { + if (groupObj instanceof Map groupMap) { + for (Map.Entry entry : groupMap.entrySet()) { + if (String.valueOf(entry.getKey()).equalsIgnoreCase(varKey) || String.valueOf(entry.getKey()).equalsIgnoreCase(strippedKey)) { + return entry.getValue() != null ? entry.getValue().toString() : ""; + } + } + } + } + } + } + + if (currentSessionData != null) { + if (currentSessionData.containsKey(varKey)) return currentSessionData.get(varKey); + if (currentSessionData.containsKey(strippedKey)) return currentSessionData.get(strippedKey); + for (Map.Entry entry : currentSessionData.entrySet()) { + if (entry.getKey().equalsIgnoreCase(varKey) || entry.getKey().equalsIgnoreCase(strippedKey)) { + return entry.getValue() != null ? entry.getValue() : ""; + } + } + } + + if (hasBraces) { + return ""; + } + + return rawValue; + } + + private boolean evaluateCondition(String actual, String operator, String expected) { + if (actual == null) actual = ""; + if (expected == null) expected = ""; + String act = actual.trim(); + String exp = expected.trim(); + + switch (operator.toLowerCase()) { + case OP_EQUALS: + case OP_IS: + return act.equalsIgnoreCase(exp); + case OP_NOT_EQUALS: + case OP_IS_NOT: + return !act.equalsIgnoreCase(exp); + case OP_CONTAINS: + return act.toLowerCase().contains(exp.toLowerCase()); + case OP_NOT_CONTAINS: + return !act.toLowerCase().contains(exp.toLowerCase()); + case OP_EXISTS: + case OP_IS_SET: + return !act.isEmpty(); + case OP_NOT_EXISTS: + case OP_IS_NOT_SET: + return act.isEmpty(); + case OP_GREATER_THAN: + case OP_GT: + try { + return Double.parseDouble(act) > Double.parseDouble(exp); + } catch (NumberFormatException e) { + return act.compareToIgnoreCase(exp) > 0; + } + case OP_LESS_THAN: + case OP_LT: + try { + return Double.parseDouble(act) < Double.parseDouble(exp); + } catch (NumberFormatException e) { + return act.compareToIgnoreCase(exp) < 0; + } + case OP_GREATER_THAN_OR_EQUALS: + case OP_GREATER_THAN_OR_EQUAL: + case OP_GTE: + case OP_GTE_SYMBOL: + try { + return Double.parseDouble(act) >= Double.parseDouble(exp); + } catch (NumberFormatException e) { + return act.compareToIgnoreCase(exp) >= 0; + } + case OP_LESS_THAN_OR_EQUALS: + case OP_LESS_THAN_OR_EQUAL: + case OP_LTE: + case OP_LTE_SYMBOL: + try { + return Double.parseDouble(act) <= Double.parseDouble(exp); + } catch (NumberFormatException e) { + return act.compareToIgnoreCase(exp) <= 0; + } + default: + return act.equalsIgnoreCase(exp); + } + } + + private Map parseMetadata(String json) { + if (json == null || json.trim().isEmpty()) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return Collections.emptyMap(); + } + } + + private String findTarget(List edges, String nodeId, String handleId) { + if (edges == null) return null; + return edges.stream() + .filter(e -> e.source().equals(nodeId)) + .filter(e -> handleId.equals(e.sourceHandle())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/SchedulerNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/SchedulerNodeExecutor.java new file mode 100644 index 00000000..9e301e37 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/SchedulerNodeExecutor.java @@ -0,0 +1,32 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import java.util.List; + +@Component +@Slf4j +public class SchedulerNodeExecutor implements NodeExecutor { + + @Override + public NodeType getType() { + return NodeType.SCHEDULER; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + log.debug("Executing SCHEDULER node {} for botUser {}", node.id(), botUser != null ? botUser.getId() : "system"); + return edges.stream() + .filter(e -> e.source().equals(node.id())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/SubscriptionCheckNodeExecutor.java b/backend/src/main/java/com/launchly/bot/engine/executor/SubscriptionCheckNodeExecutor.java new file mode 100644 index 00000000..3ad87540 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/SubscriptionCheckNodeExecutor.java @@ -0,0 +1,236 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.service.BotDialogStateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.methods.groupadministration.GetChatMember; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMember; +import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberAdministrator; +import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberMember; +import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberOwner; +import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberRestricted; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionCheckNodeExecutor implements NodeExecutor { + + private static final List PASS_HANDLES = List.of("subscribed", "true", "yes"); + private static final List FAIL_HANDLES = List.of("not_subscribed", "false", "no"); + private static final String MODE_ANY = "any"; + private static final String MODE_ALL = "all"; + private static final String KEY_MODE = "mode"; + private static final String KEY_PASS_VAR = "passVariable"; + private static final String KEY_UNSUBS_VAR = "unsubscribedVariable"; + private static final String KEY_CHANNELS = "channels"; + private static final String KEY_CHANNEL_ID = "channelId"; + private static final String KEY_CHANNEL_LEGACY = "channel"; + private static final String KEY_CHANNEL_NAME = "name"; + private static final String KEY_IS_REQUIRED = "isRequired"; + private static final String DEFAULT_PASS_VAR = "is_subscribed"; + private static final String DEFAULT_UNSUBS_VAR = "unsubscribed_channels"; + private static final String VAR_SUBSCRIBED_COUNT = "subscribed_channels_count"; + private static final String VAR_TOTAL_COUNT = "total_channels_count"; + private static final Set SUBSCRIBED_STATUS_STRINGS = Set.of( + "creator", + "owner", + "administrator", + "member" + ); + private static final List TELEGRAM_URL_PREFIXES = List.of( + "https://t.me/", + "http://t.me/", + "t.me/" + ); + private final BotDialogStateService stateService; + + @Override + public NodeType getType() { + return NodeType.SUBSCRIPTION_CHECK; + } + + @Override + public String execute(FlowNode node, List edges, BotUser botUser, + Update update, TelegramClient client) { + Long botId = botUser.getBot().getId(); + Long telegramUserId = botUser.getTelegramId(); + Map data = node.data() != null ? node.data() : Collections.emptyMap(); + + String mode = (String) data.getOrDefault(KEY_MODE, MODE_ALL); + String passVar = (String) data.getOrDefault(KEY_PASS_VAR, DEFAULT_PASS_VAR); + String unsubsVar = (String) data.getOrDefault(KEY_UNSUBS_VAR, DEFAULT_UNSUBS_VAR); + + List> channels = extractChannels(data); + + int totalRequired = 0; + int totalSubscribed = 0; + List unsubscribedChannelsList = new ArrayList<>(); + + if (client != null && telegramUserId != null) { + for (Map ch : channels) { + String rawChatId = getChannelId(ch); + if (rawChatId == null || rawChatId.isBlank()) { + continue; + } + + boolean isRequired = ch.get(KEY_IS_REQUIRED) == null || Boolean.TRUE.equals(ch.get(KEY_IS_REQUIRED)); + if (isRequired) { + totalRequired++; + } + + String chatId = normalizeChatId(rawChatId); + String channelTitle = (String) ch.getOrDefault(KEY_CHANNEL_NAME, chatId); + if (channelTitle == null || channelTitle.isBlank()) { + channelTitle = chatId; + } + + boolean isMember = checkMembership(client, chatId, telegramUserId); + if (isMember) { + if (isRequired) { + totalSubscribed++; + } + } else if (isRequired) { + unsubscribedChannelsList.add(channelTitle); + } + } + } + + boolean passed = evaluatePassCondition(mode, totalSubscribed, totalRequired); + + saveSessionResults(botId, telegramUserId, passVar, unsubsVar, passed, totalSubscribed, totalRequired, unsubscribedChannelsList); + + return resolveNextTarget(edges, node.id(), passed); + } + + private boolean evaluatePassCondition(String mode, int totalSubscribed, int totalRequired) { + if (totalRequired == 0) { + return true; + } + if (MODE_ANY.equalsIgnoreCase(mode)) { + return totalSubscribed > 0; + } + return totalSubscribed >= totalRequired; + } + + private boolean checkMembership(TelegramClient client, String chatId, Long telegramUserId) { + try { + GetChatMember getChatMember = GetChatMember.builder() + .chatId(chatId) + .userId(telegramUserId) + .build(); + + ChatMember chatMember = client.execute(getChatMember); + if (chatMember == null) { + return false; + } + + if (chatMember instanceof ChatMemberOwner + || chatMember instanceof ChatMemberAdministrator + || chatMember instanceof ChatMemberMember) { + return true; + } + + if (chatMember instanceof ChatMemberRestricted restricted) { + return Boolean.TRUE.equals(restricted.getIsMember()); + } + + String status = chatMember.getStatus(); + if (status != null && SUBSCRIBED_STATUS_STRINGS.contains(status.trim().toLowerCase())) { + return true; + } + + return false; + } catch (Exception e) { + log.warn("Failed to check chat member {} in {}: {}", telegramUserId, chatId, e.getMessage()); + return false; + } + } + + private String resolveNextTarget(List edges, String nodeId, boolean passed) { + List preferredHandles = passed ? PASS_HANDLES : FAIL_HANDLES; + + for (String handle : preferredHandles) { + String target = findTarget(edges, nodeId, handle); + if (target != null) { + return target; + } + } + + return edges.stream() + .filter(e -> e.source().equals(nodeId)) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } + + @SuppressWarnings("unchecked") + private List> extractChannels(Map data) { + if (data.get(KEY_CHANNELS) instanceof List list) { + return (List>) list; + } + if (data.containsKey(KEY_CHANNEL_ID) || data.containsKey(KEY_CHANNEL_LEGACY)) { + String chId = (String) data.getOrDefault(KEY_CHANNEL_ID, data.get(KEY_CHANNEL_LEGACY)); + if (chId != null && !chId.isBlank()) { + return List.of(Map.of( + KEY_CHANNEL_ID, chId, + KEY_CHANNEL_NAME, data.getOrDefault("channelName", chId), + KEY_IS_REQUIRED, true + )); + } + } + return Collections.emptyList(); + } + + private String getChannelId(Map ch) { + String id = (String) ch.get(KEY_CHANNEL_ID); + if (id == null || id.isBlank()) { + id = (String) ch.get(KEY_CHANNEL_LEGACY); + } + return id; + } + + private String normalizeChatId(String raw) { + String trimmed = raw.trim(); + for (String prefix : TELEGRAM_URL_PREFIXES) { + if (trimmed.startsWith(prefix)) { + trimmed = trimmed.substring(prefix.length()); + break; + } + } + + if (trimmed.startsWith("@") || trimmed.startsWith("-") || trimmed.matches("^-?\\d+$") || trimmed.startsWith("+")) { + return trimmed; + } + return "@" + trimmed; + } + + private void saveSessionResults(Long botId, Long telegramUserId, String passVar, String unsubsVar, + boolean passed, int totalSubscribed, int totalRequired, List unsubscribedList) { + stateService.setSessionData(botId, telegramUserId, passVar, String.valueOf(passed)); + stateService.setSessionData(botId, telegramUserId, unsubsVar, String.join(", ", unsubscribedList)); + stateService.setSessionData(botId, telegramUserId, VAR_SUBSCRIBED_COUNT, String.valueOf(totalSubscribed)); + stateService.setSessionData(botId, telegramUserId, VAR_TOTAL_COUNT, String.valueOf(totalRequired)); + } + + private String findTarget(List edges, String nodeId, String handleId) { + return edges.stream() + .filter(e -> e.source().equals(nodeId)) + .filter(e -> handleId.equals(e.sourceHandle())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/AudioMessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/AudioMessageBlockHandler.java new file mode 100644 index 00000000..f90a0c2b --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/AudioMessageBlockHandler.java @@ -0,0 +1,67 @@ +package com.launchly.bot.engine.executor.block; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.methods.send.SendAudio; +import org.telegram.telegrambots.meta.api.objects.InputFile; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import java.io.InputStream; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AudioMessageBlockHandler implements MessageBlockHandler { + + private final MessageBlockHelper helper; + + @Override + public String getSupportedType() { + return "audio"; + } + + @Override + public MessageBlockResult handle(MessageBlockContext context) { + Map block = context.block(); + String blockAudioUrl = (String) block.get("audioUrl"); + if (blockAudioUrl == null || blockAudioUrl.trim().isEmpty()) { + return MessageBlockResult.ok(false); + } + + boolean isHttp = blockAudioUrl.startsWith("http://") || blockAudioUrl.startsWith("https://"); + if (isHttp) { + String fileName = (String) block.get("fileName"); + if (fileName == null || fileName.trim().isEmpty()) { + fileName = helper.extractFileName(blockAudioUrl); + if (!fileName.contains(".")) { + fileName += ".mp3"; + } + } + try (InputStream stream = helper.openUrlStream(blockAudioUrl)) { + SendAudio sendAudio = SendAudio.builder() + .chatId(context.chatId()) + .audio(new InputFile(stream, fileName)) + .replyMarkup(context.markup()) + .build(); + context.client().execute(sendAudio); + } catch (Exception e) { + log.error("Failed to send audio stream in node {}: {}", context.node().id(), e.getMessage()); + } + } else { + try { + SendAudio sendAudio = SendAudio.builder() + .chatId(context.chatId()) + .audio(new InputFile(blockAudioUrl)) + .replyMarkup(context.markup()) + .build(); + context.client().execute(sendAudio); + } catch (TelegramApiException e) { + log.error("Failed to send audio block in node {}: {}", context.node().id(), e.getMessage()); + } + } + + return MessageBlockResult.ok(context.markup() != null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/DataCollectionMessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/DataCollectionMessageBlockHandler.java new file mode 100644 index 00000000..6f1bebf5 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/DataCollectionMessageBlockHandler.java @@ -0,0 +1,74 @@ +package com.launchly.bot.engine.executor.block; + +import com.launchly.bot.engine.model.DataCollectionState; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; +import tools.jackson.databind.ObjectMapper; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DataCollectionMessageBlockHandler implements MessageBlockHandler { + + private final MessageBlockHelper helper; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public String getSupportedType() { + return "data_collection"; + } + + @Override + public MessageBlockResult handle(MessageBlockContext context) { + Map block = context.block(); + String blockText = (String) block.getOrDefault("text", ""); + if (blockText != null && !blockText.trim().isEmpty()) { + String resolvedText = helper.resolvePlaceholders(blockText, context.sessionData(), context.botUser()); + String escapedText = helper.escapeHtml(resolvedText); + String htmlText = helper.convertMarkdownLinksToHtml(escapedText); + + try { + SendMessage message = SendMessage.builder() + .chatId(context.chatId()) + .text(htmlText) + .parseMode("HTML") + .build(); + context.client().execute(message); + } catch (TelegramApiException e) { + log.error("Failed to send data collection question in node {}: {}", context.node().id(), e.getMessage()); + } + } + + try { + String replyType = (String) block.getOrDefault("replyType", "Text"); + String variableName = (String) block.getOrDefault("variableName", ""); + Object expObj = block.get("expirationMinutes"); + int expirationMinutes = expObj instanceof Number number ? number.intValue() : 30; + Object retryObj = block.get("retryCount"); + int retryCount = retryObj instanceof Number number ? number.intValue() : 3; + + DataCollectionState state = DataCollectionState.builder() + .nodeId(context.node().id()) + .blockId((String) block.get("id")) + .replyType(replyType) + .saveToField(variableName) + .retryCount(retryCount) + .expiresAt(System.currentTimeMillis() + (expirationMinutes * 60 * 1000L)) + .build(); + + String dcKey = "launchly:bot:data_collection:" + context.botUser().getBot().getId() + ":" + context.botUser().getTelegramId(); + redisTemplate.opsForValue().set(dcKey, objectMapper.writeValueAsString(state)); + } catch (Exception e) { + log.error("Failed to save data collection state: {}", e.getMessage(), e); + } + + return MessageBlockResult.halt(); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/DelayMessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/DelayMessageBlockHandler.java new file mode 100644 index 00000000..4fd35cec --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/DelayMessageBlockHandler.java @@ -0,0 +1,48 @@ +package com.launchly.bot.engine.executor.block; + +import com.launchly.bot.constant.BotConstants; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class DelayMessageBlockHandler implements MessageBlockHandler { + + private static final int MAX_DELAY_SECONDS = 60; + + @Override + public String getSupportedType() { + return "delay"; + } + + @Override + public MessageBlockResult handle(MessageBlockContext context) { + Map block = context.block(); + int delaySeconds = BotConstants.DEFAULT_DELAY_SECONDS; + Object delayObj = block.get("delaySeconds"); + if (delayObj instanceof Number number) { + delaySeconds = number.intValue(); + } else if (delayObj instanceof String str) { + try { + delaySeconds = Integer.parseInt(str); + } catch (NumberFormatException e) { + log.warn("Invalid delaySeconds string '{}' in node {}, fallback to default", delayObj, context.node().id()); + } + } + + int boundedDelay = Math.max(0, Math.min(delaySeconds, MAX_DELAY_SECONDS)); + if (boundedDelay > 0) { + try { + TimeUnit.SECONDS.sleep(boundedDelay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Delay interrupted in node {}", context.node().id()); + } + } + + return MessageBlockResult.ok(false); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/FileMessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/FileMessageBlockHandler.java new file mode 100644 index 00000000..c875d5a5 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/FileMessageBlockHandler.java @@ -0,0 +1,64 @@ +package com.launchly.bot.engine.executor.block; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.methods.send.SendDocument; +import org.telegram.telegrambots.meta.api.objects.InputFile; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import java.io.InputStream; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FileMessageBlockHandler implements MessageBlockHandler { + + private final MessageBlockHelper helper; + + @Override + public String getSupportedType() { + return "file"; + } + + @Override + public MessageBlockResult handle(MessageBlockContext context) { + Map block = context.block(); + String blockFileUrl = (String) block.get("fileUrl"); + if (blockFileUrl == null || blockFileUrl.trim().isEmpty()) { + return MessageBlockResult.ok(false); + } + + boolean isHttp = blockFileUrl.startsWith("http://") || blockFileUrl.startsWith("https://"); + if (isHttp) { + String fileName = (String) block.get("fileName"); + if (fileName == null || fileName.trim().isEmpty()) { + fileName = helper.extractFileName(blockFileUrl); + } + try (InputStream stream = helper.openUrlStream(blockFileUrl)) { + SendDocument sendDocument = SendDocument.builder() + .chatId(context.chatId()) + .document(new InputFile(stream, fileName)) + .replyMarkup(context.markup()) + .build(); + context.client().execute(sendDocument); + } catch (Exception e) { + log.error("Failed to send file stream in node {}: {}", context.node().id(), e.getMessage()); + } + } else { + try { + SendDocument sendDocument = SendDocument.builder() + .chatId(context.chatId()) + .document(new InputFile(blockFileUrl)) + .replyMarkup(context.markup()) + .build(); + context.client().execute(sendDocument); + } catch (TelegramApiException e) { + log.error("Failed to send file block in node {}: {}", context.node().id(), e.getMessage()); + } + } + + return MessageBlockResult.ok(context.markup() != null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/ImageMessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/ImageMessageBlockHandler.java new file mode 100644 index 00000000..30151cdb --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/ImageMessageBlockHandler.java @@ -0,0 +1,86 @@ +package com.launchly.bot.engine.executor.block; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.methods.send.SendPhoto; +import org.telegram.telegrambots.meta.api.objects.InputFile; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; +import java.io.InputStream; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ImageMessageBlockHandler implements MessageBlockHandler { + + private final MessageBlockHelper helper; + + @Override + public String getSupportedType() { + return "image"; + } + + @Override + public MessageBlockResult handle(MessageBlockContext context) { + Map block = context.block(); + String rawImageUrl = (String) block.get("imageUrl"); + if (rawImageUrl == null || rawImageUrl.trim().isEmpty()) { + return MessageBlockResult.ok(false); + } + + String blockImageUrl = helper.resolvePlaceholders(rawImageUrl, context.sessionData(), context.botUser()); + if (blockImageUrl == null || blockImageUrl.trim().isEmpty()) { + return MessageBlockResult.ok(false); + } + + String caption = (String) block.get("caption"); + if (caption == null || caption.isBlank()) { + String rawText = (String) block.get("text"); + if (rawText != null && !rawText.isBlank()) { + String resolvedText = helper.resolvePlaceholders(rawText, context.sessionData(), context.botUser()); + String escapedText = helper.escapeHtml(resolvedText); + caption = helper.convertMarkdownLinksToHtml(escapedText); + } + } + + boolean isHttp = blockImageUrl.startsWith("http://") || blockImageUrl.startsWith("https://"); + if (isHttp) { + String fileName = (String) block.get("fileName"); + if (fileName == null || fileName.trim().isEmpty()) { + fileName = helper.extractFileName(blockImageUrl); + if (!fileName.contains(".")) { + fileName += ".jpg"; + } + } + try (InputStream stream = helper.openUrlStream(blockImageUrl)) { + SendPhoto.SendPhotoBuilder sendPhotoBuilder = SendPhoto.builder() + .chatId(context.chatId()) + .photo(new InputFile(stream, fileName)) + .replyMarkup(context.markup()); + if (caption != null && !caption.isBlank()) { + sendPhotoBuilder.caption(caption).parseMode("HTML"); + } + context.client().execute(sendPhotoBuilder.build()); + } catch (Exception e) { + log.error("Failed to send image stream in node {}: {}", context.node().id(), e.getMessage()); + } + } else { + try { + SendPhoto.SendPhotoBuilder sendPhotoBuilder = SendPhoto.builder() + .chatId(context.chatId()) + .photo(new InputFile(blockImageUrl)) + .replyMarkup(context.markup()); + if (caption != null && !caption.isBlank()) { + sendPhotoBuilder.caption(caption).parseMode("HTML"); + } + context.client().execute(sendPhotoBuilder.build()); + } catch (TelegramApiException e) { + log.error("Failed to send image block in node {}: {}", context.node().id(), e.getMessage()); + } + } + + return MessageBlockResult.ok(context.markup() != null); + } +} + diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockContext.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockContext.java new file mode 100644 index 00000000..38a782de --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockContext.java @@ -0,0 +1,20 @@ +package com.launchly.bot.engine.executor.block; + +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.Map; + +public record MessageBlockContext( + Map block, + FlowNode node, + BotUser botUser, + String chatId, + Map sessionData, + InlineKeyboardMarkup markup, + TelegramClient client, + int blockIndex, + int lastSendableIndex +) {} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockHandler.java new file mode 100644 index 00000000..8080866c --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockHandler.java @@ -0,0 +1,8 @@ +package com.launchly.bot.engine.executor.block; + +public interface MessageBlockHandler { + + String getSupportedType(); + + MessageBlockResult handle(MessageBlockContext context); +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockHelper.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockHelper.java new file mode 100644 index 00000000..fee72966 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockHelper.java @@ -0,0 +1,304 @@ +package com.launchly.bot.engine.executor.block; + +import com.launchly.bot.entity.BotUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; +import tools.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MessageBlockHelper { + + private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]]+)\\]\\(([^\\s)]+)\\)"); + + private final ObjectMapper objectMapper; + + public InlineKeyboardMarkup buildMarkup(List buttonsList) { + if (buttonsList == null || buttonsList.isEmpty()) { + return null; + } + List rows = new ArrayList<>(); + InlineKeyboardRow currentRow = null; + String lastRowStr = null; + + for (Object btnObj : buttonsList) { + if (btnObj instanceof Map btn) { + Object labelObj = btn.get("label"); + String label = labelObj instanceof String ? (String) labelObj : "Button"; + Object valueObj = btn.get("value"); + String value = valueObj instanceof String ? (String) valueObj : label; + Object rowObj = btn.get("row"); + String rowStr = rowObj != null ? rowObj.toString() : null; + + InlineKeyboardButton button = InlineKeyboardButton.builder() + .text(label) + .callbackData(value) + .build(); + + if (rowStr != null && !rowStr.trim().isEmpty()) { + if (currentRow == null || !rowStr.equals(lastRowStr)) { + currentRow = new InlineKeyboardRow(); + rows.add(currentRow); + lastRowStr = rowStr; + } + currentRow.add(button); + } else { + currentRow = new InlineKeyboardRow(button); + rows.add(currentRow); + lastRowStr = null; + } + } + } + return rows.isEmpty() ? null : InlineKeyboardMarkup.builder().keyboard(rows).build(); + } + + public String extractFileName(String url) { + if (url == null || url.trim().isEmpty()) { + return "file"; + } + int lastSlash = url.lastIndexOf('/'); + if (lastSlash != -1 && lastSlash < url.length() - 1) { + String candidate = url.substring(lastSlash + 1); + int questionMark = candidate.indexOf('?'); + if (questionMark != -1) { + candidate = candidate.substring(0, questionMark); + } + return candidate; + } + return "file"; + } + + public InputStream openUrlStream(String urlString) throws IOException { + URL url = URI.create(urlString).toURL(); + URLConnection connection = url.openConnection(); + connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + return connection.getInputStream(); + } + + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{+([^{}]+)\\}+"); + + public String resolvePlaceholders(String text, Map variables, BotUser botUser) { + if (text == null) return ""; + String result = text; + + Matcher matcher = PLACEHOLDER_PATTERN.matcher(result); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + String rawName = matcher.group(1).trim(); + String cleanName = rawName.replaceAll("^\\{+|\\}+$", "").trim(); + String strippedName = cleanName.replaceFirst("^(?i)(custom_fields|customFields|fields|custom_field|field)\\.", "").trim(); + String replacement = ""; + boolean found = false; + + if (cleanName.equalsIgnoreCase("chat_type") || cleanName.equalsIgnoreCase("Chat Type")) { + replacement = variables != null ? variables.getOrDefault("chat_type", "private") : "private"; + found = true; + } else if (cleanName.equalsIgnoreCase("chat_id") || cleanName.equalsIgnoreCase("Chat Id") || cleanName.equalsIgnoreCase("Chat ID")) { + replacement = (botUser != null && botUser.getTelegramId() != null) + ? String.valueOf(botUser.getTelegramId()) + : (variables != null ? variables.getOrDefault("chat_id", "") : ""); + found = true; + } else if (cleanName.equalsIgnoreCase("chat_title") || cleanName.equalsIgnoreCase("Chat Title")) { + replacement = variables != null ? variables.getOrDefault("chat_title", "") : ""; + found = true; + } else if (cleanName.equalsIgnoreCase("first_name") || cleanName.equalsIgnoreCase("First Name")) { + if (botUser != null && botUser.getFirstName() != null && !botUser.getFirstName().trim().isEmpty()) { + replacement = botUser.getFirstName(); + } else if (variables != null && variables.containsKey("first_name")) { + replacement = variables.get("first_name"); + } else if (variables != null && variables.containsKey("First Name")) { + replacement = variables.get("First Name"); + } else { + replacement = ""; + } + found = true; + } else if (cleanName.equalsIgnoreCase("last_name") || cleanName.equalsIgnoreCase("Last Name")) { + if (botUser != null && botUser.getLastName() != null && !botUser.getLastName().trim().isEmpty()) { + replacement = botUser.getLastName(); + } else if (variables != null && variables.containsKey("last_name")) { + replacement = variables.get("last_name"); + } else if (variables != null && variables.containsKey("Last Name")) { + replacement = variables.get("Last Name"); + } else { + replacement = ""; + } + found = true; + } else if (cleanName.equalsIgnoreCase("username") || cleanName.equalsIgnoreCase("telegram_username") || cleanName.equalsIgnoreCase("Telegram Username")) { + String username = botUser != null ? botUser.getUsername() : null; + if (username != null && !username.trim().isEmpty()) { + replacement = username.startsWith("@") ? username : "@" + username; + } else if (variables != null && variables.containsKey("username") && variables.get("username") != null && !variables.get("username").trim().isEmpty()) { + String u = variables.get("username").trim(); + replacement = u.startsWith("@") ? u : "@" + u; + } else if (variables != null && variables.containsKey("telegram_username") && variables.get("telegram_username") != null && !variables.get("telegram_username").trim().isEmpty()) { + String u = variables.get("telegram_username").trim(); + replacement = u.startsWith("@") ? u : "@" + u; + } else { + replacement = ""; + } + found = true; + } else if (cleanName.equalsIgnoreCase("telegram_user_id") || cleanName.equalsIgnoreCase("Telegram User ID")) { + replacement = (botUser != null && botUser.getTelegramId() != null) ? String.valueOf(botUser.getTelegramId()) : ""; + found = true; + } else if (cleanName.equalsIgnoreCase("contact_id") || cleanName.equalsIgnoreCase("Contact Id")) { + replacement = (botUser != null && botUser.getId() != null) ? String.valueOf(botUser.getId()) : ""; + found = true; + } else if (cleanName.equalsIgnoreCase("phone") || cleanName.equalsIgnoreCase("Phone")) { + replacement = variables != null ? variables.getOrDefault("phone", "") : ""; + found = true; + } else if (cleanName.equalsIgnoreCase("email") || cleanName.equalsIgnoreCase("Email")) { + replacement = variables != null ? variables.getOrDefault("email", "") : ""; + found = true; + } else if (cleanName.equalsIgnoreCase("subscribed") || cleanName.equalsIgnoreCase("Subscribed")) { + replacement = variables != null ? variables.getOrDefault("telegram_opt_in", "false") : "false"; + found = true; + } else if (cleanName.equalsIgnoreCase("last_reply_type") || cleanName.equalsIgnoreCase("Last Reply Type")) { + replacement = variables != null ? variables.getOrDefault("last_reply_type", "text") : "text"; + found = true; + } else if (cleanName.equalsIgnoreCase("photo_url") || cleanName.equalsIgnoreCase("Photo Url") + || cleanName.equalsIgnoreCase("photo") || cleanName.equalsIgnoreCase("Photo") + || cleanName.equalsIgnoreCase("avatar") || cleanName.equalsIgnoreCase("Avatar")) { + if (botUser != null && botUser.getPhotoUrl() != null && !botUser.getPhotoUrl().trim().isEmpty()) { + replacement = botUser.getPhotoUrl(); + found = true; + } else if (variables != null && variables.containsKey("photo_url")) { + replacement = variables.get("photo_url"); + found = true; + } else if (variables != null && variables.containsKey("found_photo_url")) { + replacement = variables.get("found_photo_url"); + found = true; + } + } + + if (!found && botUser != null && botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { + try { + Map metaMap = this.objectMapper.readValue(botUser.getMetadata(), Map.class); + Map customFields = (Map) metaMap.get("customFields"); + if (customFields != null) { + for (Map.Entry entry : customFields.entrySet()) { + if (entry.getKey().equalsIgnoreCase(cleanName) || entry.getKey().equalsIgnoreCase(strippedName)) { + replacement = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + found = true; + break; + } + } + } + + if (!found) { + Map chatCustomFields = (Map) metaMap.get("chatCustomFields"); + if (chatCustomFields != null) { + String targetScope = variables != null ? variables.get("chat_id") : null; + if (targetScope != null && chatCustomFields.get(targetScope) instanceof Map scopeMap) { + for (Map.Entry entry : scopeMap.entrySet()) { + if (String.valueOf(entry.getKey()).equalsIgnoreCase(cleanName) || String.valueOf(entry.getKey()).equalsIgnoreCase(strippedName)) { + replacement = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + found = true; + break; + } + } + } + if (!found) { + for (Object groupVal : chatCustomFields.values()) { + if (groupVal instanceof Map scopeMap) { + for (Map.Entry entry : scopeMap.entrySet()) { + if (String.valueOf(entry.getKey()).equalsIgnoreCase(cleanName) || String.valueOf(entry.getKey()).equalsIgnoreCase(strippedName)) { + replacement = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + found = true; + break; + } + } + if (found) break; + } + } + } + } + } + + if (!found) { + for (Map.Entry entry : metaMap.entrySet()) { + if (entry.getKey().equalsIgnoreCase(cleanName) || entry.getKey().equalsIgnoreCase(strippedName)) { + replacement = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + found = true; + break; + } + } + } + } catch (Exception e) { + log.warn("Failed to parse metadata for placeholder {}: {}", cleanName, e.getMessage()); + } + } + + if (!found && variables != null) { + for (Map.Entry entry : variables.entrySet()) { + if (entry.getKey().equalsIgnoreCase(cleanName) || entry.getKey().equalsIgnoreCase(strippedName)) { + replacement = entry.getValue() != null ? entry.getValue() : ""; + if (cleanName.toLowerCase().contains("username") && replacement != null && !replacement.isEmpty() && !replacement.startsWith("@")) { + replacement = "@" + replacement; + } + found = true; + break; + } + } + if (!found && cleanName.toLowerCase().startsWith("found_user.")) { + String subKey = cleanName.substring("found_user.".length()); + for (Map.Entry entry : variables.entrySet()) { + if (entry.getKey().equalsIgnoreCase("found_" + subKey) || entry.getKey().equalsIgnoreCase(cleanName)) { + replacement = entry.getValue() != null ? entry.getValue() : ""; + if (subKey.toLowerCase().contains("username") && replacement != null && !replacement.isEmpty() && !replacement.startsWith("@")) { + replacement = "@" + replacement; + } + found = true; + break; + } + } + } + } + + if (!found) { + replacement = ""; + } + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + public String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } + + public String convertMarkdownLinksToHtml(String text) { + if (text == null) return ""; + Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(text); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + String linkText = matcher.group(1); + String url = matcher.group(2).trim(); + if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("tg://")) { + url = "https://" + url; + } + String htmlLink = "" + linkText + ""; + matcher.appendReplacement(sb, Matcher.quoteReplacement(htmlLink)); + } + matcher.appendTail(sb); + return sb.toString(); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockResult.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockResult.java new file mode 100644 index 00000000..b4f4adc5 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/MessageBlockResult.java @@ -0,0 +1,12 @@ +package com.launchly.bot.engine.executor.block; + +public record MessageBlockResult(boolean hasButtons, boolean haltFlow) { + + public static MessageBlockResult ok(boolean hasButtons) { + return new MessageBlockResult(hasButtons, false); + } + + public static MessageBlockResult halt() { + return new MessageBlockResult(false, true); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/TextMessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/TextMessageBlockHandler.java new file mode 100644 index 00000000..fd04934e --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/TextMessageBlockHandler.java @@ -0,0 +1,49 @@ +package com.launchly.bot.engine.executor.block; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TextMessageBlockHandler implements MessageBlockHandler { + + private final MessageBlockHelper helper; + + @Override + public String getSupportedType() { + return "text"; + } + + @Override + public MessageBlockResult handle(MessageBlockContext context) { + Map block = context.block(); + String blockText = (String) block.getOrDefault("text", ""); + if (blockText == null || blockText.trim().isEmpty()) { + return MessageBlockResult.ok(false); + } + + String resolvedText = helper.resolvePlaceholders(blockText, context.sessionData(), context.botUser()); + String escapedText = helper.escapeHtml(resolvedText); + String htmlText = helper.convertMarkdownLinksToHtml(escapedText); + + try { + SendMessage message = SendMessage.builder() + .chatId(context.chatId()) + .text(htmlText) + .parseMode("HTML") + .replyMarkup(context.markup()) + .build(); + context.client().execute(message); + } catch (TelegramApiException e) { + log.error("Failed to send text block in node {}: {}", context.node().id(), e.getMessage()); + } + + return MessageBlockResult.ok(context.markup() != null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/executor/block/VideoMessageBlockHandler.java b/backend/src/main/java/com/launchly/bot/engine/executor/block/VideoMessageBlockHandler.java new file mode 100644 index 00000000..c1e845b1 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/executor/block/VideoMessageBlockHandler.java @@ -0,0 +1,67 @@ +package com.launchly.bot.engine.executor.block; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.meta.api.methods.send.SendVideo; +import org.telegram.telegrambots.meta.api.objects.InputFile; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import java.io.InputStream; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class VideoMessageBlockHandler implements MessageBlockHandler { + + private final MessageBlockHelper helper; + + @Override + public String getSupportedType() { + return "video"; + } + + @Override + public MessageBlockResult handle(MessageBlockContext context) { + Map block = context.block(); + String blockVideoUrl = (String) block.get("videoUrl"); + if (blockVideoUrl == null || blockVideoUrl.trim().isEmpty()) { + return MessageBlockResult.ok(false); + } + + boolean isHttp = blockVideoUrl.startsWith("http://") || blockVideoUrl.startsWith("https://"); + if (isHttp) { + String fileName = (String) block.get("fileName"); + if (fileName == null || fileName.trim().isEmpty()) { + fileName = helper.extractFileName(blockVideoUrl); + if (!fileName.contains(".")) { + fileName += ".mp4"; + } + } + try (InputStream stream = helper.openUrlStream(blockVideoUrl)) { + SendVideo sendVideo = SendVideo.builder() + .chatId(context.chatId()) + .video(new InputFile(stream, fileName)) + .replyMarkup(context.markup()) + .build(); + context.client().execute(sendVideo); + } catch (Exception e) { + log.error("Failed to send video stream in node {}: {}", context.node().id(), e.getMessage()); + } + } else { + try { + SendVideo sendVideo = SendVideo.builder() + .chatId(context.chatId()) + .video(new InputFile(blockVideoUrl)) + .replyMarkup(context.markup()) + .build(); + context.client().execute(sendVideo); + } catch (TelegramApiException e) { + log.error("Failed to send video block in node {}: {}", context.node().id(), e.getMessage()); + } + } + + return MessageBlockResult.ok(context.markup() != null); + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/persister/BotMessagePersister.java b/backend/src/main/java/com/launchly/bot/engine/persister/BotMessagePersister.java new file mode 100644 index 00000000..1536e744 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/persister/BotMessagePersister.java @@ -0,0 +1,157 @@ +package com.launchly.bot.engine.persister; + +import com.launchly.bot.engine.executor.block.MessageBlockHelper; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.service.BotDialogStateService; +import com.launchly.crm.service.CrmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BotMessagePersister { + + private final CrmService crmService; + private final MessageBlockHelper messageBlockHelper; + private final BotDialogStateService stateService; + + @SuppressWarnings("unchecked") + public void saveBotNodeMessage(Long botId, BotUser botUser, FlowNode node) { + try { + Map data = node.data(); + if (data == null) return; + + Map sessionData = (botId != null && botUser != null && botUser.getTelegramId() != null) + ? stateService.getSessionData(botId, botUser.getTelegramId()) + : Map.of(); + + Object blocksObj = data.get("blocks"); + if (blocksObj instanceof List blocks && !blocks.isEmpty()) { + for (Object blockObj : blocks) { + if (blockObj instanceof Map block) { + String type = (String) block.get("type"); + if ("text".equals(type) || "data_collection".equals(type)) { + StringBuilder text = new StringBuilder(); + Object t = block.get("text"); + if (t instanceof String s && !s.isBlank()) { + String resolved = messageBlockHelper.resolvePlaceholders(s, sessionData, botUser); + text.append(resolved); + } + Object btns = block.get("buttons"); + if (btns instanceof List btnList) { + for (Object btn : btnList) { + if (btn instanceof Map b) { + Object lbl = b.get("label"); + if (lbl instanceof String l) { + text.append(" [").append(l).append("]"); + } + } + } + } + if (text.length() > 0) { + crmService.saveBotMessage(botId, botUser.getId(), text.toString(), null, null); + } + } else if ("image".equals(type)) { + String imageUrl = (String) block.get("imageUrl"); + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + StringBuilder caption = new StringBuilder(); + Object t = block.get("text"); + if (t instanceof String s && !s.isBlank()) { + caption.append(messageBlockHelper.resolvePlaceholders(s, sessionData, botUser)); + } else { + Object cap = block.get("caption"); + if (cap instanceof String c && !c.isBlank()) { + caption.append(messageBlockHelper.resolvePlaceholders(c, sessionData, botUser)); + } + } + crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[Image]", imageUrl, "IMAGE"); + } + } else if ("video".equals(type)) { + String videoUrl = (String) block.get("videoUrl"); + if (videoUrl != null && !videoUrl.trim().isEmpty()) { + StringBuilder caption = new StringBuilder(); + Object t = block.get("text"); + if (t instanceof String s && !s.isBlank()) { + caption.append(messageBlockHelper.resolvePlaceholders(s, sessionData, botUser)); + } else { + Object cap = block.get("caption"); + if (cap instanceof String c && !c.isBlank()) { + caption.append(messageBlockHelper.resolvePlaceholders(c, sessionData, botUser)); + } + } + crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[Video]", videoUrl, "VIDEO"); + } + } else if ("audio".equals(type)) { + String audioUrl = (String) block.get("audioUrl"); + if (audioUrl != null && !audioUrl.trim().isEmpty()) { + StringBuilder caption = new StringBuilder(); + Object t = block.get("text"); + if (t instanceof String s && !s.isBlank()) { + caption.append(messageBlockHelper.resolvePlaceholders(s, sessionData, botUser)); + } else { + Object cap = block.get("caption"); + if (cap instanceof String c && !c.isBlank()) { + caption.append(messageBlockHelper.resolvePlaceholders(c, sessionData, botUser)); + } + } + crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[Audio]", audioUrl, "AUDIO"); + } + } else if ("file".equals(type)) { + String fileUrl = (String) block.get("fileUrl"); + if (fileUrl != null && !fileUrl.trim().isEmpty()) { + String fileName = (String) block.get("fileName"); + StringBuilder caption = new StringBuilder(); + if (fileName != null && !fileName.isBlank()) { + caption.append(fileName); + } + Object t = block.get("text"); + if (t instanceof String s && !s.isBlank()) { + if (caption.length() > 0) caption.append(": "); + caption.append(messageBlockHelper.resolvePlaceholders(s, sessionData, botUser)); + } + crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[File]", fileUrl, "FILE"); + } + } + } + } + } else { + String text = (String) data.getOrDefault("text", ""); + String imageUrl = (String) data.get("imageUrl"); + List buttonsList = (List) data.get("buttons"); + + StringBuilder content = new StringBuilder(); + if (text != null && !text.isBlank()) { + String resolved = messageBlockHelper.resolvePlaceholders(text, sessionData, botUser); + content.append(resolved); + } + if (buttonsList != null) { + for (Object btn : buttonsList) { + if (btn instanceof Map b) { + Object lbl = b.get("label"); + if (lbl instanceof String l) { + content.append(" [").append(l).append("]"); + } + } + } + } + + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + if (content.length() == 0) { + content.append("📷 Photo"); + } + crmService.saveBotMessage(botId, botUser.getId(), content.toString(), imageUrl, "image"); + } else if (content.length() > 0) { + crmService.saveBotMessage(botId, botUser.getId(), content.toString(), null, null); + } + } + } catch (Exception e) { + log.error("Failed to save bot message in CRM for bot {}: {}", botId, e.getMessage(), e); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/router/FlowNodeRouter.java b/backend/src/main/java/com/launchly/bot/engine/router/FlowNodeRouter.java new file mode 100644 index 00000000..97446906 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/router/FlowNodeRouter.java @@ -0,0 +1,172 @@ +package com.launchly.bot.engine.router; + +import com.launchly.bot.engine.cache.FlowSchemaCache; +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.FlowSchema; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.service.BotDialogStateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FlowNodeRouter { + + private final FlowSchemaCache schemaCache; + private final ObjectMapper objectMapper; + + public String resolveCurrentNodeId(Long botId, Long telegramUserId, BotUser botUser, List nodes, BotDialogStateService stateService) { + Optional redisNodeId = stateService.getCurrentNodeId(botId, telegramUserId); + if (redisNodeId.isPresent() && !redisNodeId.get().trim().isEmpty()) { + return redisNodeId.get(); + } + + if (botUser.getCurrentNodeId() != null && !botUser.getCurrentNodeId().trim().isEmpty()) { + stateService.setCurrentNodeId(botId, telegramUserId, botUser.getCurrentNodeId()); + return botUser.getCurrentNodeId(); + } + + return nodes.stream() + .filter(n -> n.type() == NodeType.START || n.type() == NodeType.START_BROADCAST) + .findFirst() + .map(FlowNode::id) + .orElse(null); + } + + public FlowNode findNodeById(List nodes, String nodeId) { + return nodes.stream() + .filter(n -> n.id().equals(nodeId)) + .findFirst() + .orElse(null); + } + + public String findTargetNodeId(List edges, String sourceNodeId, String sourceHandle) { + return edges.stream() + .filter(e -> e.source().equals(sourceNodeId) && sourceHandle.equals(e.sourceHandle())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + } + + public FlowNode findMatchingCommandNode(List nodes, String text) { + if (text == null || !text.trim().startsWith("/") || nodes == null) { + return null; + } + String cmdToken = text.trim().split("\\s+")[0].split("@")[0].replaceAll("^/+", "").trim(); + if (cmdToken.isEmpty()) { + return null; + } + return nodes.stream() + .filter(n -> n.type() == NodeType.COMMAND && n.data() != null) + .filter(n -> { + Object cmdObj = n.data().get("command"); + if (cmdObj == null) return false; + String configuredCmd = cmdObj.toString().trim().split("\\s+")[0].split("@")[0].replaceAll("^/+", "").trim(); + return cmdToken.equalsIgnoreCase(configuredCmd); + }) + .findFirst() + .orElse(null); + } + + public FlowNode findMatchingJoinRequestNode(List nodes, String channelId, String inviteLink) { + if (nodes == null || nodes.isEmpty()) { + return null; + } + return nodes.stream() + .filter(n -> n.type() == NodeType.JOIN_REQUEST) + .filter(n -> { + if (n.data() == null) return true; + Object channelObj = n.data().get("channelId"); + if (channelObj == null || channelObj.toString().trim().isEmpty() || "*".equals(channelObj.toString().trim())) { + return true; + } + String confChannel = channelObj.toString().trim().replace("@", ""); + if (channelId != null) { + String cleanTarget = channelId.trim().replace("@", ""); + return confChannel.equalsIgnoreCase(cleanTarget); + } + return true; + }) + .findFirst() + .orElse(null); + } + + @SuppressWarnings("unchecked") + public String resolveButtonLabel(Long botId, String callbackData) { + if (callbackData == null || callbackData.isBlank()) { + return ""; + } + try { + FlowSchema schema = schemaCache.getSchema(botId); + if (schema == null || schema.getNodes() == null) { + return callbackData; + } + + List nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); + for (FlowNode node : nodes) { + if (node.data() == null) continue; + + Object topBtnsObj = node.data().get("buttons"); + if (topBtnsObj instanceof List topBtns) { + for (Object btnObj : topBtns) { + if (btnObj instanceof Map btn) { + Object val = btn.get("value"); + Object id = btn.get("id"); + Object targetNodeId = btn.get("targetNodeId"); + if (callbackData.equalsIgnoreCase(String.valueOf(val)) + || callbackData.equalsIgnoreCase(String.valueOf(id)) + || callbackData.equalsIgnoreCase(String.valueOf(targetNodeId))) { + Object label = btn.get("label"); + if (label == null) label = btn.get("text"); + if (label == null) label = btn.get("name"); + if (label != null && !label.toString().isBlank()) { + return label.toString(); + } + } + } + } + } + + Object blocksObj = node.data().get("blocks"); + if (blocksObj instanceof List blocks) { + for (Object blockObj : blocks) { + if (blockObj instanceof Map block) { + Object btnsObj = block.get("buttons"); + if (btnsObj instanceof List buttons) { + for (Object btnObj : buttons) { + if (btnObj instanceof Map btn) { + Object val = btn.get("value"); + Object id = btn.get("id"); + Object targetNodeId = btn.get("targetNodeId"); + if (callbackData.equalsIgnoreCase(String.valueOf(val)) + || callbackData.equalsIgnoreCase(String.valueOf(id)) + || callbackData.equalsIgnoreCase(String.valueOf(targetNodeId))) { + Object label = btn.get("label"); + if (label == null) label = btn.get("text"); + if (label == null) label = btn.get("name"); + if (label != null && !label.toString().isBlank()) { + return label.toString(); + } + } + } + } + } + } + } + } + } + } catch (Exception e) { + log.warn("Failed to resolve button label for callback data {} in bot {}: {}", callbackData, botId, e.getMessage()); + } + return callbackData; + } +} diff --git a/backend/src/main/java/com/launchly/bot/engine/validator/BotInputValidator.java b/backend/src/main/java/com/launchly/bot/engine/validator/BotInputValidator.java index 7a248b40..f7c41cf0 100644 --- a/backend/src/main/java/com/launchly/bot/engine/validator/BotInputValidator.java +++ b/backend/src/main/java/com/launchly/bot/engine/validator/BotInputValidator.java @@ -12,55 +12,98 @@ @RequiredArgsConstructor public class BotInputValidator { - private final MessageUtils messageUtils; + public static final long MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024L; + public static final int MAX_TEXT_LENGTH = 2000; + public static final int MAX_EMAIL_LENGTH = 254; + + private static final String MSG_KEY_IMAGE = "bot.validation.image"; + private static final String MSG_KEY_EMAIL = "bot.validation.email"; + private static final String MSG_KEY_PHONE = "bot.validation.phone"; + private static final String MSG_KEY_NUMBER = "bot.validation.number"; + private static final String MSG_KEY_INVALID_FORMAT = "bot.validation.invalid_format"; + private static final String MSG_KEY_IMAGE_SIZE_LIMIT = "bot.validation.image_size_limit"; + private static final String MSG_KEY_IMAGE_INVALID_FORMAT = "bot.validation.image_invalid_format"; + private static final String MSG_KEY_TEXT_LENGTH_LIMIT = "bot.validation.text_length_limit"; - private static final String NUMBER_REGEX = "-?\\d+(\\.\\d+)?"; - private static final String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@(.+)$"; - private static final String PHONE_REGEX = "^\\+?[0-9\\s\\-\\(\\)]+$"; + private final MessageUtils messageUtils; public boolean validate(String text, String replyType) { - if (text == null || text.trim().isEmpty()) { + ValidationType validationType = ValidationType.fromString(replyType); + if (validationType == ValidationType.IMAGE || validationType == ValidationType.PHOTO) { + return false; + } + if (text == null) { return false; } - if ("Number".equalsIgnoreCase(replyType)) { - return text.matches(NUMBER_REGEX); + String trimmed = text.trim(); + if (trimmed.isEmpty() || trimmed.length() > MAX_TEXT_LENGTH) { + return false; } - if ("Email".equalsIgnoreCase(replyType)) { - return text.matches(EMAIL_REGEX); + + if (validationType == ValidationType.NUMBER) { + if (validationType.getPattern() != null && !validationType.getPattern().matcher(trimmed).matches()) { + return false; + } + try { + double val = Double.parseDouble(trimmed); + return !Double.isNaN(val) && !Double.isInfinite(val); + } catch (NumberFormatException e) { + return false; + } } - if ("Phone".equalsIgnoreCase(replyType)) { - return text.matches(PHONE_REGEX); + + if (validationType == ValidationType.EMAIL) { + if (trimmed.length() > MAX_EMAIL_LENGTH) { + return false; + } + return validationType.getPattern() != null && validationType.getPattern().matcher(trimmed).matches(); + } + + if (validationType.getPattern() != null) { + return validationType.getPattern().matcher(trimmed).matches(); } return true; } public String getValidationErrorMessage(String replyType) { - if ("Email".equalsIgnoreCase(replyType)) { - return messageUtils.getMessageWithDefault( - "bot.validation.email", - "Please enter a valid email address (e.g., name@example.com)."); + ValidationType validationType = ValidationType.fromString(replyType); + switch (validationType) { + case IMAGE: + case PHOTO: + return messageUtils.getMessage(MSG_KEY_IMAGE); + case EMAIL: + return messageUtils.getMessage(MSG_KEY_EMAIL); + case PHONE: + return messageUtils.getMessage(MSG_KEY_PHONE); + case NUMBER: + return messageUtils.getMessage(MSG_KEY_NUMBER); + default: + return messageUtils.getMessage(MSG_KEY_INVALID_FORMAT); } - if ("Phone".equalsIgnoreCase(replyType)) { - return messageUtils.getMessageWithDefault( - "bot.validation.phone", - "Please enter a valid phone number (e.g., +380123456789)."); - } - if ("Number".equalsIgnoreCase(replyType)) { - return messageUtils.getMessageWithDefault( - "bot.validation.number", - "Please enter a valid number."); - } - return messageUtils.getMessageWithDefault( - "bot.validation.invalid_format", - "Invalid format. Please enter valid data."); + } + + public String getImageSizeErrorMessage() { + return messageUtils.getMessage(MSG_KEY_IMAGE_SIZE_LIMIT); + } + + public String getImageFormatErrorMessage() { + return messageUtils.getMessage(MSG_KEY_IMAGE_INVALID_FORMAT); + } + + public String getTextLengthErrorMessage() { + return messageUtils.getMessage(MSG_KEY_TEXT_LENGTH_LIMIT); } public void sendValidationErrorMessage(String chatId, String replyType, TelegramClient client) { String msgText = getValidationErrorMessage(replyType); + sendCustomErrorMessage(chatId, msgText, client); + } + + public void sendCustomErrorMessage(String chatId, String messageText, TelegramClient client) { try { SendMessage message = SendMessage.builder() .chatId(chatId) - .text(msgText) + .text(messageText) .build(); client.execute(message); } catch (Exception e) { diff --git a/backend/src/main/java/com/launchly/bot/engine/validator/ValidationType.java b/backend/src/main/java/com/launchly/bot/engine/validator/ValidationType.java new file mode 100644 index 00000000..9b43fb32 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/engine/validator/ValidationType.java @@ -0,0 +1,32 @@ +package com.launchly.bot.engine.validator; + +import java.util.regex.Pattern; + +public enum ValidationType { + NUMBER(Pattern.compile("^-?\\d{1,15}(\\.\\d{1,10})?$")), + EMAIL(Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")), + PHONE(Pattern.compile("^\\+?[0-9\\s\\-\\(\\)]{5,25}$")), + IMAGE(null), + PHOTO(null), + TEXT(null); + + private final Pattern pattern; + + ValidationType(Pattern pattern) { + this.pattern = pattern; + } + + public Pattern getPattern() { + return pattern; + } + + public static ValidationType fromString(String type) { + if (type == null) return TEXT; + try { + return valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + return TEXT; + } + } +} + diff --git a/backend/src/main/java/com/launchly/bot/entity/AccountTemplate.java b/backend/src/main/java/com/launchly/bot/entity/AccountTemplate.java index ff66a104..3b523285 100644 --- a/backend/src/main/java/com/launchly/bot/entity/AccountTemplate.java +++ b/backend/src/main/java/com/launchly/bot/entity/AccountTemplate.java @@ -9,8 +9,10 @@ @Table(name = "account_templates", indexes = { @Index(name = "idx_account_templates_creator_created", columnList = "creator_id, created_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"creator", "sourceBot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/bot/entity/Bot.java b/backend/src/main/java/com/launchly/bot/entity/Bot.java index 3f1d524d..e368a3c9 100644 --- a/backend/src/main/java/com/launchly/bot/entity/Bot.java +++ b/backend/src/main/java/com/launchly/bot/entity/Bot.java @@ -4,6 +4,8 @@ import com.launchly.common.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.Index; @@ -12,11 +14,14 @@ import jakarta.persistence.Version; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import java.time.LocalDateTime; @Entity @Table(name = "bots", indexes = { @@ -24,8 +29,10 @@ @Index(name = "idx_bots_active", columnList = "is_active"), @Index(name = "idx_bots_user_updated", columnList = "user_id, updated_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"user"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor @@ -67,7 +74,7 @@ public class Bot extends BaseEntity { private String blockReason; @Column(name = "blocked_at") - private java.time.LocalDateTime blockedAt; + private LocalDateTime blockedAt; @JdbcTypeCode(SqlTypes.JSON) @Column(name = "custom_fields_data", columnDefinition = "jsonb") @@ -84,6 +91,11 @@ public class Bot extends BaseEntity { @Builder.Default private int runsCount = 1; + @Enumerated(EnumType.STRING) + @Column(name = "response_mode", nullable = false) + @Builder.Default + private BotResponseMode responseMode = BotResponseMode.ALL; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @@ -103,7 +115,7 @@ public void block(String reason) { this.blocked = true; this.active = false; this.blockReason = reason != null && !reason.isBlank() ? reason.trim() : "Bot blocked by administrator"; - this.blockedAt = java.time.LocalDateTime.now(); + this.blockedAt = LocalDateTime.now(); } public void unblock() { @@ -126,5 +138,9 @@ public void updateDetails(String name, String description, String avatar, String this.avatarPublicId = avatarPublicId; } } + + public BotResponseMode getResponseMode() { + return responseMode != null ? responseMode : BotResponseMode.ALL; + } } diff --git a/backend/src/main/java/com/launchly/bot/entity/BotInvitation.java b/backend/src/main/java/com/launchly/bot/entity/BotInvitation.java index d1d6b3bb..c42c0ee7 100644 --- a/backend/src/main/java/com/launchly/bot/entity/BotInvitation.java +++ b/backend/src/main/java/com/launchly/bot/entity/BotInvitation.java @@ -10,9 +10,11 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; @Entity @Table(name = "bot_invitations", indexes = { @@ -20,8 +22,10 @@ @Index(name = "idx_bot_invitations_email", columnList = "email"), @Index(name = "idx_bot_invitations_email_accepted", columnList = "email, accepted") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/bot/entity/BotMember.java b/backend/src/main/java/com/launchly/bot/entity/BotMember.java index 8e5c02f1..173327b8 100644 --- a/backend/src/main/java/com/launchly/bot/entity/BotMember.java +++ b/backend/src/main/java/com/launchly/bot/entity/BotMember.java @@ -11,9 +11,11 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; @Entity @Table(name = "bot_members", indexes = { @@ -21,8 +23,10 @@ @Index(name = "idx_bot_members_user_id", columnList = "user_id"), @Index(name = "idx_bot_members_user_bot", columnList = "user_id, bot_id") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot", "user"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/bot/entity/BotModerationRule.java b/backend/src/main/java/com/launchly/bot/entity/BotModerationRule.java new file mode 100644 index 00000000..b115e89f --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/entity/BotModerationRule.java @@ -0,0 +1,88 @@ +package com.launchly.bot.entity; + +import com.launchly.bot.constant.ModerationConstants; +import com.launchly.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "bot_moderation_rules", indexes = { + @Index(name = "idx_bmr_bot_chat", columnList = "bot_id, chat_id"), + @Index(name = "idx_bmr_bot_enabled", columnList = "bot_id, enabled") +}) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BotModerationRule extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bot_id", nullable = false) + private Bot bot; + + @Column(name = "chat_id", nullable = false, length = 128) + @Builder.Default + private String chatId = "*"; + + @Column(name = "thread_id") + private Integer threadId; + + @Column(name = "enabled", nullable = false) + @Builder.Default + private boolean enabled = true; + + @Column(name = "anti_forward_enabled", nullable = false) + @Builder.Default + private boolean antiForwardEnabled = false; + + @Column(name = "anti_link_enabled", nullable = false) + @Builder.Default + private boolean antiLinkEnabled = false; + + @Column(name = "allowed_links", columnDefinition = "TEXT") + private String allowedLinks; + + @Column(name = "stop_words", columnDefinition = "TEXT") + private String stopWords; + + @Column(name = "default_profanity_filter", nullable = false) + @Builder.Default + private boolean defaultProfanityFilter = true; + + @Enumerated(EnumType.STRING) + @Column(name = "media_mode", nullable = false, length = 32) + @Builder.Default + private MediaMode mediaMode = MediaMode.ALL; + + @Enumerated(EnumType.STRING) + @Column(name = "action_on_violation", nullable = false, length = 32) + @Builder.Default + private ViolationAction actionOnViolation = ViolationAction.DELETE_AND_WARN; + + @Column(name = "warning_template", columnDefinition = "TEXT") + @Builder.Default + private String warningTemplate = ModerationConstants.DEFAULT_WARNING_TEMPLATE; + + @Column(name = "warn_ttl_seconds", nullable = false) + @Builder.Default + private Integer warnTtlSeconds = 5; + + @Column(name = "captcha_enabled", nullable = false) + @Builder.Default + private boolean captchaEnabled = false; + + @Enumerated(EnumType.STRING) + @Column(name = "captcha_mode", nullable = false, length = 32) + @Builder.Default + private CaptchaMode captchaMode = CaptchaMode.BUTTON; + + @Column(name = "captcha_timeout_seconds", nullable = false) + @Builder.Default + private Integer captchaTimeoutSeconds = 60; + + @Column(name = "captcha_message_template", columnDefinition = "TEXT") + private String captchaMessageTemplate; +} diff --git a/backend/src/main/java/com/launchly/bot/entity/BotResponseMode.java b/backend/src/main/java/com/launchly/bot/entity/BotResponseMode.java new file mode 100644 index 00000000..05131fd3 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/entity/BotResponseMode.java @@ -0,0 +1,7 @@ +package com.launchly.bot.entity; + +public enum BotResponseMode { + ALL, + PRIVATE_ONLY, + GROUPS_ONLY +} diff --git a/backend/src/main/java/com/launchly/bot/entity/BotUser.java b/backend/src/main/java/com/launchly/bot/entity/BotUser.java index de5293b9..d6deec4f 100644 --- a/backend/src/main/java/com/launchly/bot/entity/BotUser.java +++ b/backend/src/main/java/com/launchly/bot/entity/BotUser.java @@ -10,7 +10,9 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; @@ -23,8 +25,10 @@ @Index(name = "idx_bot_users_bot_telegram", columnList = "bot_id, telegram_id"), @Index(name = "idx_bot_users_bot_created", columnList = "bot_id, created_at") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor @@ -65,5 +69,22 @@ public void updateTelegramProfile(String firstName, String lastName, String user public void setCurrentNode(String nodeId) { this.currentNodeId = nodeId; } + + public String getDisplayName() { + String first = firstName != null ? firstName.trim() : ""; + String last = lastName != null ? lastName.trim() : ""; + String full = (first + " " + last).trim(); + if (!full.isEmpty()) { + return full; + } + if (username != null && !username.isBlank()) { + String u = username.trim(); + return u.startsWith("@") ? u : "@" + u; + } + if (telegramId != null) { + return "User " + telegramId; + } + return "Unknown"; + } } diff --git a/backend/src/main/java/com/launchly/bot/entity/BotUserInteraction.java b/backend/src/main/java/com/launchly/bot/entity/BotUserInteraction.java new file mode 100644 index 00000000..5e446625 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/entity/BotUserInteraction.java @@ -0,0 +1,46 @@ +package com.launchly.bot.entity; + +import com.launchly.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Table(name = "bot_user_interactions", indexes = { + @Index(name = "idx_bui_bot_src_type", columnList = "bot_id, source_telegram_id, interaction_type"), + @Index(name = "idx_bui_bot_tgt_type", columnList = "bot_id, target_telegram_id, interaction_type"), + @Index(name = "idx_bui_bot_src_tgt", columnList = "bot_id, source_telegram_id, target_telegram_id") +}) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BotUserInteraction extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bot_id", nullable = false) + private Bot bot; + + @Column(name = "source_telegram_id", nullable = false) + private Long sourceTelegramId; + + @Column(name = "target_telegram_id", nullable = false) + private Long targetTelegramId; + + @Column(name = "interaction_type", nullable = false, length = 64) + private String interactionType; +} diff --git a/backend/src/main/java/com/launchly/bot/entity/CaptchaMode.java b/backend/src/main/java/com/launchly/bot/entity/CaptchaMode.java new file mode 100644 index 00000000..8017c656 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/entity/CaptchaMode.java @@ -0,0 +1,6 @@ +package com.launchly.bot.entity; + +public enum CaptchaMode { + BUTTON, + MATH +} diff --git a/backend/src/main/java/com/launchly/bot/entity/FlowSchema.java b/backend/src/main/java/com/launchly/bot/entity/FlowSchema.java index 136a8b45..c7864b6f 100644 --- a/backend/src/main/java/com/launchly/bot/entity/FlowSchema.java +++ b/backend/src/main/java/com/launchly/bot/entity/FlowSchema.java @@ -7,25 +7,28 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; -import jakarta.persistence.Version; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @Entity @Table(name = "flow_schemas") -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor public class FlowSchema extends BaseEntity { - @Version + @Column(name = "version", nullable = false) @Builder.Default private int version = 1; @@ -39,7 +42,31 @@ public class FlowSchema extends BaseEntity { @Builder.Default private String edges = "[]"; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "published_nodes", columnDefinition = "jsonb") + @Builder.Default + private String publishedNodes = "[]"; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "published_edges", columnDefinition = "jsonb") + @Builder.Default + private String publishedEdges = "[]"; + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "bot_id", nullable = false, unique = true) private Bot bot; + + public String getEffectivePublishedNodes() { + if (publishedNodes != null && !publishedNodes.isBlank() && !"[]".equals(publishedNodes.trim())) { + return publishedNodes; + } + return nodes; + } + + public String getEffectivePublishedEdges() { + if (publishedEdges != null && !publishedEdges.isBlank() && !"[]".equals(publishedEdges.trim())) { + return publishedEdges; + } + return edges; + } } diff --git a/backend/src/main/java/com/launchly/bot/entity/InstalledTemplate.java b/backend/src/main/java/com/launchly/bot/entity/InstalledTemplate.java index 6bf2339d..22e5b867 100644 --- a/backend/src/main/java/com/launchly/bot/entity/InstalledTemplate.java +++ b/backend/src/main/java/com/launchly/bot/entity/InstalledTemplate.java @@ -10,8 +10,10 @@ @Index(name = "idx_installed_templates_user_created", columnList = "user_id, created_at DESC"), @Index(name = "idx_installed_templates_bot", columnList = "bot_id") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"user", "bot", "template"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/bot/entity/MediaMode.java b/backend/src/main/java/com/launchly/bot/entity/MediaMode.java new file mode 100644 index 00000000..8748257b --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/entity/MediaMode.java @@ -0,0 +1,7 @@ +package com.launchly.bot.entity; + +public enum MediaMode { + ALL, + TEXT_ONLY, + MEDIA_ONLY +} diff --git a/backend/src/main/java/com/launchly/bot/entity/NodeType.java b/backend/src/main/java/com/launchly/bot/entity/NodeType.java index 0525445c..7edc9ea8 100644 --- a/backend/src/main/java/com/launchly/bot/entity/NodeType.java +++ b/backend/src/main/java/com/launchly/bot/entity/NodeType.java @@ -16,6 +16,16 @@ public enum NodeType { SMART_DELAY, RANDOMIZER, COMMENT, - AI + AI, + COMMAND, + MATH, + LEADERBOARD, + COOLDOWN, + SCHEDULER, + QUERY, + INTERACTION, + SUBSCRIPTION_CHECK, + MODERATION, + JOIN_REQUEST } diff --git a/backend/src/main/java/com/launchly/bot/entity/ViolationAction.java b/backend/src/main/java/com/launchly/bot/entity/ViolationAction.java new file mode 100644 index 00000000..dde820dd --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/entity/ViolationAction.java @@ -0,0 +1,8 @@ +package com.launchly.bot.entity; + +public enum ViolationAction { + DELETE_ONLY, + DELETE_AND_WARN, + DELETE_AND_MUTE, + DELETE_AND_KICK +} diff --git a/backend/src/main/java/com/launchly/bot/entity/WorkspaceRole.java b/backend/src/main/java/com/launchly/bot/entity/WorkspaceRole.java new file mode 100644 index 00000000..0da9ac8e --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/entity/WorkspaceRole.java @@ -0,0 +1,48 @@ +package com.launchly.bot.entity; + +public enum WorkspaceRole { + OWNER("Owner", 4), + ADMIN("Admin", 3), + EDITOR("Editor", 2), + VIEWER("Viewer", 1); + + private final String value; + private final int privilege; + + WorkspaceRole(String value, int privilege) { + this.value = value; + this.privilege = privilege; + } + + public String getValue() { + return value; + } + + public int getPrivilege() { + return privilege; + } + + public static WorkspaceRole from(String role) { + if (role == null) { + return VIEWER; + } + for (WorkspaceRole r : values()) { + if (r.value.equalsIgnoreCase(role) || r.name().equalsIgnoreCase(role)) { + return r; + } + } + return VIEWER; + } + + public static int resolvePrivilege(String role) { + if (role == null) { + return 0; + } + for (WorkspaceRole r : values()) { + if (r.value.equalsIgnoreCase(role) || r.name().equalsIgnoreCase(role)) { + return r.privilege; + } + } + return 0; + } +} diff --git a/backend/src/main/java/com/launchly/bot/mapper/BotMapper.java b/backend/src/main/java/com/launchly/bot/mapper/BotMapper.java index 8dce9457..5c91b2f5 100644 --- a/backend/src/main/java/com/launchly/bot/mapper/BotMapper.java +++ b/backend/src/main/java/com/launchly/bot/mapper/BotMapper.java @@ -18,5 +18,7 @@ public interface BotMapper { BotResponse toBotResponse(Bot bot); @Mapping(target = "tags", ignore = true) + @Mapping(target = "botId", source = "bot.id") + @Mapping(target = "botName", source = "bot.name") BotUserResponse toBotUserResponse(BotUser botUser); } diff --git a/backend/src/main/java/com/launchly/bot/mapper/BotResponseFactory.java b/backend/src/main/java/com/launchly/bot/mapper/BotResponseFactory.java new file mode 100644 index 00000000..4f8dfe7a --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/mapper/BotResponseFactory.java @@ -0,0 +1,157 @@ +package com.launchly.bot.mapper; + +import com.launchly.bot.constant.BotConstants; +import com.launchly.bot.dto.response.BotResponse; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotMember; +import com.launchly.bot.entity.WorkspaceRole; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.validator.BotAccessValidator; +import com.launchly.common.security.CustomUserDetails; +import com.launchly.common.utils.EncryptionUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BotResponseFactory { + + private final BotMapper botMapper; + private final EncryptionUtil encryptionUtil; + private final BotUserRepository botUserRepository; + private final BotAccessValidator botAccessValidator; + + public BotResponse toBotResponseWithStats(Bot bot) { + if (bot == null) return null; + BotResponse response = botMapper.toBotResponse(bot); + + boolean hasToken = false; + try { + String decryptedToken = encryptionUtil.decrypt(bot.getTelegramToken()); + hasToken = decryptedToken != null && !decryptedToken.isBlank() && !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(decryptedToken); + } catch (Exception e) { + log.error("Failed to decrypt token for bot id={}", bot.getId(), e); + } + + long totalUsers = hasToken ? botUserRepository.countByBotId(bot.getId()) : 0; + + String role = WorkspaceRole.OWNER.getValue(); + try { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof CustomUserDetails userDetails) { + Long currentUserId = userDetails.getId(); + if (!bot.getUser().getId().equals(currentUserId)) { + role = botAccessValidator.getWorkspaceMembership(bot, currentUserId) + .map(BotMember::getRole) + .orElse(WorkspaceRole.VIEWER.getValue()); + } + } + } catch (Exception e) { + log.error("Failed to determine member role in toBotResponseWithStats", e); + } + + return new BotResponse( + response.id(), + response.name(), + response.username(), + response.description(), + response.avatar(), + response.avatarPublicId(), + bot.isBlocked() ? false : response.active(), + bot.isBlocked(), + bot.getBlockReason(), + response.createdAt(), + response.updatedAt(), + totalUsers, + hasToken, + role, + bot.isTemplate(), + bot.getTemplateName(), + bot.getRunsCount(), + bot.getResponseMode() + ); + } + + public List toBotResponseListWithStats(List bots, Long currentUserId, List memberships) { + if (bots == null || bots.isEmpty()) { + return List.of(); + } + + List botIds = bots.stream().map(Bot::getId).toList(); + Map countsByBotId = new HashMap<>(); + try { + List groupedCounts = botUserRepository.countGroupedByBotIdIn(botIds); + for (Object[] row : groupedCounts) { + Long bId = (Long) row[0]; + Long count = ((Number) row[1]).longValue(); + countsByBotId.put(bId, count); + } + } catch (Exception e) { + log.error("Failed to load grouped bot user counts: {}", e.getMessage()); + } + + Map rolesByBotId = new HashMap<>(); + Map rolesByOwnerId = new HashMap<>(); + if (memberships != null) { + for (BotMember bm : memberships) { + if (bm.getBot() != null && bm.getRole() != null) { + rolesByBotId.put(bm.getBot().getId(), bm.getRole()); + if (bm.getBot().getUser() != null) { + rolesByOwnerId.put(bm.getBot().getUser().getId(), bm.getRole()); + } + } + } + } + + List result = new ArrayList<>(bots.size()); + for (Bot bot : bots) { + BotResponse response = botMapper.toBotResponse(bot); + boolean hasToken = false; + try { + String decryptedToken = encryptionUtil.decrypt(bot.getTelegramToken()); + hasToken = decryptedToken != null && !decryptedToken.isBlank() && !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(decryptedToken); + } catch (Exception e) { + log.error("Failed to decrypt token for bot id={}", bot.getId(), e); + } + + long totalUsers = hasToken ? countsByBotId.getOrDefault(bot.getId(), 0L) : 0L; + + String role = WorkspaceRole.OWNER.getValue(); + if (currentUserId != null && !bot.getUser().getId().equals(currentUserId)) { + role = rolesByBotId.getOrDefault(bot.getId(), + rolesByOwnerId.getOrDefault(bot.getUser().getId(), WorkspaceRole.VIEWER.getValue())); + } + + result.add(new BotResponse( + response.id(), + response.name(), + response.username(), + response.description(), + response.avatar(), + response.avatarPublicId(), + bot.isBlocked() ? false : response.active(), + bot.isBlocked(), + bot.getBlockReason(), + response.createdAt(), + response.updatedAt(), + totalUsers, + hasToken, + role, + bot.isTemplate(), + bot.getTemplateName(), + bot.getRunsCount(), + bot.getResponseMode() + )); + } + + return result; + } +} diff --git a/backend/src/main/java/com/launchly/bot/repository/BotMemberRepository.java b/backend/src/main/java/com/launchly/bot/repository/BotMemberRepository.java index 4e9e0b82..602c1286 100644 --- a/backend/src/main/java/com/launchly/bot/repository/BotMemberRepository.java +++ b/backend/src/main/java/com/launchly/bot/repository/BotMemberRepository.java @@ -9,35 +9,35 @@ public interface BotMemberRepository extends JpaRepository { - @EntityGraph(attributePaths = {"user", "bot"}) + @EntityGraph(attributePaths = {"user", "bot", "bot.user"}) List findByBotId(Long botId); - @EntityGraph(attributePaths = {"user", "bot"}) + @EntityGraph(attributePaths = {"user", "bot", "bot.user"}) List findByUserId(Long userId); - @EntityGraph(attributePaths = {"user", "bot"}) + @EntityGraph(attributePaths = {"user", "bot", "bot.user"}) Optional findByBotIdAndUserId(Long botId, Long userId); @Override - @EntityGraph(attributePaths = {"user", "bot"}) + @EntityGraph(attributePaths = {"user", "bot", "bot.user"}) Optional findById(Long id); boolean existsByBotIdAndUserId(Long botId, Long userId); void deleteByBotIdAndUserId(Long botId, Long userId); - @EntityGraph(attributePaths = {"user", "bot"}) + @EntityGraph(attributePaths = {"user", "bot", "bot.user"}) @Query("SELECT bm FROM BotMember bm WHERE bm.user.id = :userId AND bm.bot.user.id = (SELECT b.user.id FROM Bot b WHERE b.id = :botId) ORDER BY CASE WHEN LOWER(bm.role) = 'admin' THEN 1 WHEN LOWER(bm.role) = 'editor' THEN 2 ELSE 3 END ASC") List findWorkspaceMemberships(Long botId, Long userId); @Query("SELECT COUNT(bm) > 0 FROM BotMember bm WHERE bm.bot.user.id = :ownerId AND bm.user.id = :userId") boolean existsByBotOwnerIdAndUserId(Long ownerId, Long userId); - @EntityGraph(attributePaths = {"user", "bot"}) + @EntityGraph(attributePaths = {"user", "bot", "bot.user"}) @Query("SELECT bm FROM BotMember bm WHERE bm.bot.user.id = :ownerId") List findByBotOwnerId(Long ownerId); - @EntityGraph(attributePaths = {"user", "bot"}) + @EntityGraph(attributePaths = {"user", "bot", "bot.user"}) @Query("SELECT bm FROM BotMember bm WHERE bm.bot.user.id = :ownerId AND bm.user.id = :userId") List findByBotOwnerIdAndUserId(Long ownerId, Long userId); } diff --git a/backend/src/main/java/com/launchly/bot/repository/BotModerationRuleRepository.java b/backend/src/main/java/com/launchly/bot/repository/BotModerationRuleRepository.java new file mode 100644 index 00000000..cca1b5d7 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/repository/BotModerationRuleRepository.java @@ -0,0 +1,19 @@ +package com.launchly.bot.repository; + +import com.launchly.bot.entity.BotModerationRule; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface BotModerationRuleRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"bot"}) + List findAllByBotId(Long botId); + + @EntityGraph(attributePaths = {"bot"}) + List findAllByBotIdAndEnabledTrue(Long botId); + + @EntityGraph(attributePaths = {"bot"}) + Optional findByBotIdAndChatId(Long botId, String chatId); +} diff --git a/backend/src/main/java/com/launchly/bot/repository/BotRepository.java b/backend/src/main/java/com/launchly/bot/repository/BotRepository.java index 79194443..b3fdf66e 100644 --- a/backend/src/main/java/com/launchly/bot/repository/BotRepository.java +++ b/backend/src/main/java/com/launchly/bot/repository/BotRepository.java @@ -4,6 +4,8 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -17,6 +19,15 @@ public interface BotRepository extends JpaRepository { long countByUserId(Long userId); + long countByActiveTrue(); + + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + long countByActiveTrueAndCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + @EntityGraph(attributePaths = {"user"}) + List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + @EntityGraph(attributePaths = {"user"}) List findAllByActiveTrue(); @@ -28,5 +39,15 @@ public interface BotRepository extends JpaRepository { @EntityGraph(attributePaths = {"user"}) Optional findById(Long id); + @EntityGraph(attributePaths = {"user"}) + @Query("SELECT DISTINCT b FROM Bot b WHERE b.user.id = :userId OR EXISTS (SELECT 1 FROM BotMember bm WHERE bm.bot.user.id = b.user.id AND bm.user.id = :userId)") + List findAllAccessibleByUserId(@Param("userId") Long userId); + + @Query("SELECT COUNT(DISTINCT b) FROM Bot b WHERE b.active = true AND (b.user.id = :userId OR EXISTS (SELECT 1 FROM BotMember bm WHERE bm.bot.user.id = b.user.id AND bm.user.id = :userId))") + long countAccessibleByUserIdAndActiveTrue(@Param("userId") Long userId); + + @Query("SELECT COUNT(DISTINCT b) FROM Bot b WHERE b.active = true AND b.createdAt < :date AND (b.user.id = :userId OR EXISTS (SELECT 1 FROM BotMember bm WHERE bm.bot.user.id = b.user.id AND bm.user.id = :userId))") + long countAccessibleByUserIdAndActiveTrueAndCreatedAtBefore(@Param("userId") Long userId, @Param("date") LocalDateTime date); + boolean existsByTelegramToken(String telegramToken); } diff --git a/backend/src/main/java/com/launchly/bot/repository/BotUserInteractionRepository.java b/backend/src/main/java/com/launchly/bot/repository/BotUserInteractionRepository.java new file mode 100644 index 00000000..e02d25bb --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/repository/BotUserInteractionRepository.java @@ -0,0 +1,30 @@ +package com.launchly.bot.repository; + +import com.launchly.bot.entity.BotUserInteraction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; +import java.util.Optional; + +public interface BotUserInteractionRepository extends JpaRepository { + + boolean existsByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType( + Long botId, Long sourceTelegramId, Long targetTelegramId, String interactionType + ); + + @Query("SELECT i.targetTelegramId FROM BotUserInteraction i WHERE i.bot.id = :botId AND i.sourceTelegramId = :sourceTelegramId AND i.interactionType IN :types") + List findInteractedTargetTelegramIds( + @Param("botId") Long botId, + @Param("sourceTelegramId") Long sourceTelegramId, + @Param("types") List types + ); + + Optional findByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType( + Long botId, Long sourceTelegramId, Long targetTelegramId, String interactionType + ); + + long countByBotIdAndTargetTelegramIdAndInteractionType( + Long botId, Long targetTelegramId, String interactionType + ); +} diff --git a/backend/src/main/java/com/launchly/bot/repository/BotUserRepository.java b/backend/src/main/java/com/launchly/bot/repository/BotUserRepository.java index f5def3aa..f9e5e74a 100644 --- a/backend/src/main/java/com/launchly/bot/repository/BotUserRepository.java +++ b/backend/src/main/java/com/launchly/bot/repository/BotUserRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -23,6 +24,14 @@ public interface BotUserRepository extends JpaRepository { long countByBotId(Long botId); + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + @EntityGraph(attributePaths = {"bot"}) + List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + @Query("SELECT bu.metadata, bu.createdAt FROM BotUser bu WHERE bu.metadata IS NOT NULL") + List findAllMetadataAndCreatedAt(); + long countByBotIdIn(List botIds); long countByBotIdAndCreatedAtBefore(Long botId, LocalDateTime date); @@ -40,4 +49,7 @@ public interface BotUserRepository extends JpaRepository { @Query("SELECT MIN(bu.telegramId) FROM BotUser bu WHERE bu.bot.id = :botId") Optional findMinTelegramIdByBotId(@Param("botId") Long botId); + + @Query("SELECT bu.bot.id, COUNT(bu.id) FROM BotUser bu WHERE bu.bot.id IN :botIds GROUP BY bu.bot.id") + List countGroupedByBotIdIn(@Param("botIds") Collection botIds); } diff --git a/backend/src/main/java/com/launchly/bot/repository/FlowSchemaRepository.java b/backend/src/main/java/com/launchly/bot/repository/FlowSchemaRepository.java index e3e76250..a2287095 100644 --- a/backend/src/main/java/com/launchly/bot/repository/FlowSchemaRepository.java +++ b/backend/src/main/java/com/launchly/bot/repository/FlowSchemaRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -14,9 +15,18 @@ public interface FlowSchemaRepository extends JpaRepository, J @EntityGraph(attributePaths = {"bot"}) Optional findByBotId(Long botId); + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + @EntityGraph(attributePaths = {"bot"}) + List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + @Override @EntityGraph(attributePaths = {"bot"}) - Optional findById(Long id); + List findAll(); + + @EntityGraph(attributePaths = {"bot"}) + @Query("SELECT fs FROM FlowSchema fs WHERE fs.bot.active = true") + List findAllByBotActiveTrue(); @Query("SELECT COUNT(fs) FROM FlowSchema fs WHERE fs.bot.user.id = :userId") long countByUserId(@Param("userId") Long userId); diff --git a/backend/src/main/java/com/launchly/bot/scheduler/FlowDelayScheduler.java b/backend/src/main/java/com/launchly/bot/scheduler/FlowDelayScheduler.java index 0be339e7..017a108d 100644 --- a/backend/src/main/java/com/launchly/bot/scheduler/FlowDelayScheduler.java +++ b/backend/src/main/java/com/launchly/bot/scheduler/FlowDelayScheduler.java @@ -29,6 +29,9 @@ @Slf4j public class FlowDelayScheduler { + private static final long DELAY_CHECK_INTERVAL_MS = 15_000L; + private static final Duration LOCK_DURATION = Duration.ofSeconds(60); + private final BotUserRepository botUserRepository; private final FlowSchemaRepository flowSchemaRepository; private final BotDialogStateService stateService; @@ -36,7 +39,7 @@ public class FlowDelayScheduler { private final ObjectMapper objectMapper; private final StringRedisTemplate stringRedisTemplate; - @Scheduled(fixedDelay = 15000) + @Scheduled(fixedDelay = DELAY_CHECK_INTERVAL_MS) public void processDelays() { List pausedUsers = botUserRepository.findByCurrentNodeIdIsNotNull(); for (BotUser user : pausedUsers) { @@ -49,12 +52,12 @@ public void processDelays() { } private void processUserDelay(BotUser user) throws Exception { - if (user == null || user.getBot() == null || user.getCurrentNodeId() == null || isAutomationPaused(user)) { + if (user == null || user.getBot() == null || user.getCurrentNodeId() == null || stateService.isAutomationPaused(user)) { return; } String lockKey = "lock:flow:delay:" + user.getId() + ":" + user.getCurrentNodeId(); - Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(60)); + Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_DURATION); if (Boolean.FALSE.equals(acquired)) { return; } @@ -66,8 +69,14 @@ private void processUserDelay(BotUser user) throws Exception { return; } - List nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - List edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); + String pubNodes = schema.getEffectivePublishedNodes(); + String pubEdges = schema.getEffectivePublishedEdges(); + if (pubNodes == null || pubNodes.isBlank()) { + return; + } + + List nodes = objectMapper.readValue(pubNodes, new TypeReference<>() {}); + List edges = objectMapper.readValue(pubEdges, new TypeReference<>() {}); FlowNode currentNode = nodes.stream() .filter(n -> n.id().equals(currentNodeId)) @@ -103,6 +112,7 @@ private void processUserDelay(BotUser user) throws Exception { long targetMs = targetTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); expired = System.currentTimeMillis() >= targetMs; } catch (Exception e) { + log.warn("Failed to parse flow delay target time '{}': {}", dateTimeStr, e.getMessage()); expired = true; } } @@ -142,6 +152,7 @@ private void processUserDelay(BotUser user) throws Exception { expired = elapsed >= durationMs; } catch (NumberFormatException e) { + log.warn("Invalid delay start timestamp '{}', resetting: {}", startStr, e.getMessage()); stateService.setSessionData(botId, user.getTelegramId(), delayKey, String.valueOf(System.currentTimeMillis())); } } @@ -153,34 +164,4 @@ private void processUserDelay(BotUser user) throws Exception { flowEngineService.runFlow(botId, user, nextNodeId, null); } } - - private boolean isAutomationPaused(BotUser botUser) { - if (botUser == null) return false; - String metadata = botUser.getMetadata(); - if (metadata == null || metadata.isBlank() || "{}".equals(metadata)) return false; - try { - Map meta = objectMapper.readValue(metadata, new TypeReference>() {}); - if (meta != null && Boolean.TRUE.equals(meta.get("paused"))) { - Object pausedUntilObj = meta.get("pausedUntil"); - if (pausedUntilObj instanceof Number) { - long pausedUntil = ((Number) pausedUntilObj).longValue(); - if (System.currentTimeMillis() > pausedUntil) { - return false; - } - } else if (pausedUntilObj instanceof String) { - try { - long pausedUntil = Long.parseLong((String) pausedUntilObj); - if (System.currentTimeMillis() > pausedUntil) { - return false; - } - } catch (NumberFormatException e) { - } - } - return true; - } - } catch (Exception e) { - log.warn("Failed to check if automation is paused for user {}: {}", botUser.getId(), e.getMessage()); - } - return false; - } } diff --git a/backend/src/main/java/com/launchly/bot/scheduler/FlowSchedulerService.java b/backend/src/main/java/com/launchly/bot/scheduler/FlowSchedulerService.java new file mode 100644 index 00000000..78b3681f --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/scheduler/FlowSchedulerService.java @@ -0,0 +1,325 @@ +package com.launchly.bot.scheduler; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.FlowSchema; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.repository.FlowSchemaRepository; +import com.launchly.bot.service.FlowEngineService; +import com.launchly.broadcast.repository.BotUserTagRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Service; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FlowSchedulerService { + + private static final long SCHEDULE_CHECK_INTERVAL_MS = 10_000L; + private static final Duration LOCK_DURATION = Duration.ofSeconds(15); + private static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Kyiv"); + + private final FlowSchemaRepository flowSchemaRepository; + private final BotUserRepository botUserRepository; + private final BotUserTagRepository botUserTagRepository; + private final FlowEngineService flowEngineService; + private final ObjectMapper objectMapper; + private final StringRedisTemplate redisTemplate; + + @Scheduled(fixedDelay = SCHEDULE_CHECK_INTERVAL_MS) + public void processScheduledFlows() { + List schemas = flowSchemaRepository.findAllByBotActiveTrue(); + for (FlowSchema schema : schemas) { + try { + processSchema(schema); + } catch (Exception e) { + log.error("Failed to process scheduled flow for botId={}", schema.getBot() != null ? schema.getBot().getId() : null, e); + } + } + } + + private void processSchema(FlowSchema schema) throws Exception { + if (schema == null || schema.getBot() == null) { + return; + } + + String pubNodes = schema.getEffectivePublishedNodes(); + String pubEdges = schema.getEffectivePublishedEdges(); + if (pubNodes == null || pubNodes.isBlank() || "[]".equals(pubNodes.trim())) { + return; + } + + if (!schema.getBot().isActive()) { + return; + } + + Long botId = schema.getBot().getId(); + List nodes = objectMapper.readValue(pubNodes, new TypeReference<>() {}); + List edges = objectMapper.readValue(pubEdges, new TypeReference<>() {}); + + List schedulerNodes = nodes.stream() + .filter(n -> n.type() == NodeType.SCHEDULER) + .toList(); + + if (schedulerNodes.isEmpty()) { + return; + } + + for (FlowNode node : schedulerNodes) { + try { + evaluateAndTriggerScheduler(botId, node, edges); + } catch (Exception e) { + log.error("Error evaluating scheduler node {} for botId={}", node.id(), botId, e); + } + } + } + + private void evaluateAndTriggerScheduler(Long botId, FlowNode node, List edges) { + Map data = node.data() != null ? node.data() : Collections.emptyMap(); + boolean isEnabled = parseBoolean(data.getOrDefault("isEnabled", data.getOrDefault("enabled", data.getOrDefault("isActive", true))), true); + if (!isEnabled) { + log.debug("Scheduler node {} is disabled for botId={}", node.id(), botId); + return; + } + + String timezoneStr = (String) data.getOrDefault("timezone", "Europe/Kyiv"); + if (timezoneStr == null || timezoneStr.isBlank() || "UTC".equalsIgnoreCase(timezoneStr)) { + timezoneStr = "Europe/Kyiv"; + } + ZoneId zoneId; + try { + zoneId = ZoneId.of(timezoneStr); + } catch (Exception e) { + zoneId = DEFAULT_ZONE; + } + + ZonedDateTime now = ZonedDateTime.now(zoneId); + String frequency = (String) data.getOrDefault("frequency", data.getOrDefault("scheduleType", "daily")); + String timeStr = (String) data.getOrDefault("time", "00:00"); + String lastRunKey; + if ("interval".equalsIgnoreCase(frequency)) { + lastRunKey = "flow:scheduler:last_run:" + botId + ":" + node.id() + ":interval"; + } else if ("cron".equalsIgnoreCase(frequency)) { + lastRunKey = "flow:scheduler:last_run:" + botId + ":" + node.id() + ":cron"; + } else { + lastRunKey = "flow:scheduler:last_run:" + botId + ":" + node.id() + ":" + frequency.toLowerCase() + ":" + timeStr; + } + + boolean isDue = isExecutionDue(lastRunKey, data, frequency, timeStr, now, zoneId); + if (isDue) { + log.info("Scheduler node {} is DUE for botId={}: freq={}, targetTime={}, nowKyiv={}", + node.id(), botId, frequency, timeStr, now.format(DateTimeFormatter.ofPattern("HH:mm:ss"))); + } else { + log.debug("Scheduler node {} botId={}: freq={}, targetTime={}, nowKyiv={}, isDue=false", + node.id(), botId, frequency, timeStr, now.format(DateTimeFormatter.ofPattern("HH:mm:ss"))); + } + + if (!isDue) { + return; + } + + String lockKey = "lock:flow:scheduler:" + botId + ":" + node.id(); + Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_DURATION); + if (Boolean.FALSE.equals(acquired)) { + log.debug("Scheduler node {} for botId={} skipped due to active lock", node.id(), botId); + return; + } + + String nextNodeId = edges.stream() + .filter(e -> e.source().equals(node.id())) + .findFirst() + .map(FlowEdge::target) + .orElse(null); + + if (nextNodeId == null || nextNodeId.isBlank()) { + log.warn("Scheduler node {} for botId={} has no outgoing connection", node.id(), botId); + return; + } + + if ("interval".equalsIgnoreCase(frequency) || "cron".equalsIgnoreCase(frequency)) { + redisTemplate.opsForValue().set(lastRunKey, String.valueOf(System.currentTimeMillis()), Duration.ofDays(7)); + } else { + redisTemplate.opsForValue().set(lastRunKey, now.toLocalDate().toString(), Duration.ofDays(7)); + } + + String targetScope = (String) data.getOrDefault("targetScope", data.getOrDefault("scope", "all")); + String targetTag = (String) data.getOrDefault("targetTag", ""); + + log.info("Triggering SCHEDULER node {} on botId={} for scope='{}' -> nextNodeId={}", + node.id(), botId, targetScope, nextNodeId); + + dispatchFlowExecution(botId, nextNodeId, targetScope, targetTag); + } + + private boolean isExecutionDue(String lastRunKey, Map data, String frequency, String timeStr, ZonedDateTime now, ZoneId zoneId) { + LocalTime targetTime; + try { + targetTime = LocalTime.parse(timeStr.trim(), DateTimeFormatter.ofPattern("HH:mm")); + } catch (Exception e) { + targetTime = LocalTime.of(9, 0); + } + + switch (frequency.toLowerCase()) { + case "interval" -> { + int intervalVal = 1; + Object valObj = data.get("intervalValue"); + if (valObj instanceof Number num) { + intervalVal = num.intValue(); + } else if (valObj instanceof String str && !str.isBlank()) { + try { + intervalVal = Integer.parseInt(str.trim()); + } catch (NumberFormatException ignored) {} + } + String intervalUnit = (String) data.getOrDefault("intervalUnit", "hours"); + long intervalMs = switch (intervalUnit.toLowerCase()) { + case "minutes" -> intervalVal * 60_000L; + case "days" -> intervalVal * 86_400_000L; + default -> intervalVal * 3_600_000L; + }; + String lastRunStr = redisTemplate.opsForValue().get(lastRunKey); + if (lastRunStr == null || lastRunStr.isBlank()) { + redisTemplate.opsForValue().set(lastRunKey, String.valueOf(System.currentTimeMillis()), Duration.ofDays(7)); + return false; + } + try { + long lastRunMs = Long.parseLong(lastRunStr.trim()); + return System.currentTimeMillis() - lastRunMs >= intervalMs; + } catch (NumberFormatException e) { + redisTemplate.opsForValue().set(lastRunKey, String.valueOf(System.currentTimeMillis()), Duration.ofDays(7)); + return false; + } + } + + case "daily" -> { + boolean isSameMinute = now.getHour() == targetTime.getHour() && now.getMinute() == targetTime.getMinute(); + if (!isSameMinute) { + return false; + } + String lastRunDate = redisTemplate.opsForValue().get(lastRunKey); + String todayStr = now.toLocalDate().toString(); + return !todayStr.equals(lastRunDate); + } + + case "weekly" -> { + Object daysObj = data.get("daysOfWeek"); + List daysList = daysObj instanceof List l ? l : List.of("MONDAY"); + String currentDayName = now.getDayOfWeek().name(); + boolean matchesDay = daysList.stream() + .anyMatch(d -> d.toString().equalsIgnoreCase(currentDayName) || + d.toString().equalsIgnoreCase(currentDayName.substring(0, 3))); + if (!matchesDay) { + return false; + } + boolean isSameMinute = now.getHour() == targetTime.getHour() && now.getMinute() == targetTime.getMinute(); + if (!isSameMinute) { + return false; + } + String lastRunDate = redisTemplate.opsForValue().get(lastRunKey); + String todayStr = now.toLocalDate().toString(); + return !todayStr.equals(lastRunDate); + } + + case "monthly" -> { + int dayOfMonth = 1; + Object domObj = data.get("dayOfMonth"); + if (domObj instanceof Number num) { + dayOfMonth = num.intValue(); + } else if (domObj instanceof String str && !str.isBlank()) { + try { + dayOfMonth = Integer.parseInt(str.trim()); + } catch (NumberFormatException ignored) {} + } + if (now.getDayOfMonth() != dayOfMonth) { + return false; + } + boolean isSameMinute = now.getHour() == targetTime.getHour() && now.getMinute() == targetTime.getMinute(); + if (!isSameMinute) { + return false; + } + String lastRunDate = redisTemplate.opsForValue().get(lastRunKey); + String todayStr = now.toLocalDate().toString(); + return !todayStr.equals(lastRunDate); + } + + case "cron" -> { + String cronExpr = (String) data.getOrDefault("cronExpression", "0 0 9 * * *"); + try { + CronExpression expression = CronExpression.parse(cronExpr.trim()); + String lastRunStr = redisTemplate.opsForValue().get(lastRunKey); + if (lastRunStr == null || lastRunStr.isBlank()) { + redisTemplate.opsForValue().set(lastRunKey, String.valueOf(System.currentTimeMillis()), Duration.ofDays(7)); + return false; + } + LocalDateTime fromDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(lastRunStr.trim())), zoneId); + LocalDateTime nextRun = expression.next(fromDateTime); + return nextRun != null && !nextRun.isAfter(now.toLocalDateTime()); + } catch (Exception e) { + log.warn("Invalid cron expression '{}': {}", cronExpr, e.getMessage()); + return false; + } + } + + default -> { + return false; + } + } + } + + private void dispatchFlowExecution(Long botId, String startNodeId, String targetScope, String targetTag) { + if ("system".equalsIgnoreCase(targetScope)) { + botUserRepository.findAllByBotId(botId).stream().findFirst().ifPresentOrElse( + user -> flowEngineService.runFlow(botId, user, startNodeId, null), + () -> log.warn("Cannot run system scheduler on botId={} because bot has 0 subscribers", botId) + ); + return; + } + + List targetUsers; + if ("tag".equalsIgnoreCase(targetScope) && targetTag != null && !targetTag.isBlank()) { + List userIds = botUserTagRepository.findBotUserIdsByTagNameAndBotId(targetTag.trim(), botId); + if (userIds.isEmpty()) { + log.info("No subscribers found with tag '{}' for botId={}", targetTag, botId); + return; + } + targetUsers = botUserRepository.findAllById(userIds); + } else { + targetUsers = botUserRepository.findAllByBotId(botId); + } + + log.info("Dispatching scheduled flow startNodeId={} to {} subscribers on botId={}", + startNodeId, targetUsers.size(), botId); + + for (BotUser user : targetUsers) { + try { + flowEngineService.runFlow(botId, user, startNodeId, null); + } catch (Exception e) { + log.error("Error executing scheduled flow for userId={} botId={}: {}", + user.getId(), botId, e.getMessage()); + } + } + } + + private boolean parseBoolean(Object value, boolean defaultValue) { + if (value == null) return defaultValue; + if (value instanceof Boolean b) return b; + return Boolean.parseBoolean(value.toString()); + } +} diff --git a/backend/src/main/java/com/launchly/bot/service/BotDialogStateService.java b/backend/src/main/java/com/launchly/bot/service/BotDialogStateService.java index d1eda2ad..29e7b0bd 100644 --- a/backend/src/main/java/com/launchly/bot/service/BotDialogStateService.java +++ b/backend/src/main/java/com/launchly/bot/service/BotDialogStateService.java @@ -1,5 +1,6 @@ package com.launchly.bot.service; +import com.launchly.bot.entity.BotUser; import java.util.Map; import java.util.Optional; @@ -26,4 +27,6 @@ public interface BotDialogStateService { void clearActiveCampaignId(Long botId, Long telegramUserId); void clearSession(Long botId, Long telegramUserId); + + boolean isAutomationPaused(BotUser botUser); } diff --git a/backend/src/main/java/com/launchly/bot/service/BotLifecycleService.java b/backend/src/main/java/com/launchly/bot/service/BotLifecycleService.java new file mode 100644 index 00000000..acec270d --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/BotLifecycleService.java @@ -0,0 +1,13 @@ +package com.launchly.bot.service; + +import com.launchly.bot.dto.response.BotResponse; +import com.launchly.bot.entity.Bot; + +public interface BotLifecycleService { + BotResponse startBot(Long id, Long userId); + BotResponse publishBot(Long id, Long userId); + BotResponse stopBot(Long id, Long userId); + void releaseTokenFromOtherBots(String token, Long userId, Long currentBotId); + void registerBot(Bot bot); + void unregisterBot(Long botId); +} diff --git a/backend/src/main/java/com/launchly/bot/service/BotModerationService.java b/backend/src/main/java/com/launchly/bot/service/BotModerationService.java new file mode 100644 index 00000000..467ab0e5 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/BotModerationService.java @@ -0,0 +1,19 @@ +package com.launchly.bot.service; + +import com.launchly.bot.dto.moderation.BotModerationRuleDto; +import com.launchly.bot.dto.moderation.TestModerationRequest; +import com.launchly.bot.dto.moderation.TestModerationResponse; +import com.launchly.bot.dto.moderation.UpdateBotModerationRuleRequest; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +public interface BotModerationService { + + BotModerationRuleDto getModerationSettings(Long botId); + + BotModerationRuleDto updateModerationSettings(Long botId, UpdateBotModerationRuleRequest request); + + boolean processUpdateModeration(Long botId, Update update, TelegramClient client); + + TestModerationResponse testModeration(Long botId, TestModerationRequest request); +} diff --git a/backend/src/main/java/com/launchly/bot/service/BotService.java b/backend/src/main/java/com/launchly/bot/service/BotService.java index eee16a91..f9091fd5 100644 --- a/backend/src/main/java/com/launchly/bot/service/BotService.java +++ b/backend/src/main/java/com/launchly/bot/service/BotService.java @@ -51,4 +51,6 @@ public interface BotService { String getAutomationFolders(Long userId); String saveAutomationFolders(String foldersJson, Long userId); + + void deleteAllUserData(Long userId); } diff --git a/backend/src/main/java/com/launchly/bot/service/BotSubscriberService.java b/backend/src/main/java/com/launchly/bot/service/BotSubscriberService.java new file mode 100644 index 00000000..8955114c --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/BotSubscriberService.java @@ -0,0 +1,13 @@ +package com.launchly.bot.service; + +import com.launchly.bot.dto.request.BotUserCreateRequest; +import com.launchly.bot.dto.request.BotUserUpdateRequest; +import com.launchly.bot.dto.response.BotUserResponse; +import java.util.List; + +public interface BotSubscriberService { + List getBotUsers(Long botId, Long userId); + BotUserResponse updateBotUser(Long botId, Long botUserId, BotUserUpdateRequest request, Long userId); + BotUserResponse createBotUser(Long botId, BotUserCreateRequest request, Long userId); + void deleteBotUser(Long botId, Long botUserId, Long userId); +} diff --git a/backend/src/main/java/com/launchly/bot/service/BotUserProvisioningService.java b/backend/src/main/java/com/launchly/bot/service/BotUserProvisioningService.java new file mode 100644 index 00000000..2ee4326f --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/BotUserProvisioningService.java @@ -0,0 +1,10 @@ +package com.launchly.bot.service; + +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +public interface BotUserProvisioningService { + BotUser getOrCreateBotUser(Bot bot, Update update, Long telegramUserId, TelegramClient telegramClient); +} diff --git a/backend/src/main/java/com/launchly/bot/service/SystemBotAuthService.java b/backend/src/main/java/com/launchly/bot/service/SystemBotAuthService.java new file mode 100644 index 00000000..7d9c6fbb --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/SystemBotAuthService.java @@ -0,0 +1,8 @@ +package com.launchly.bot.service; + +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +public interface SystemBotAuthService { + void handleSystemBotUpdate(Update update, TelegramClient client); +} diff --git a/backend/src/main/java/com/launchly/bot/service/UserAvatarService.java b/backend/src/main/java/com/launchly/bot/service/UserAvatarService.java new file mode 100644 index 00000000..dc213199 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/UserAvatarService.java @@ -0,0 +1,12 @@ +package com.launchly.bot.service; + +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +public interface UserAvatarService { + + void fetchAndSetPhotoUrl(BotUser botUser); + + void fetchAndSetPhotoUrl(BotUser botUser, Bot bot, TelegramClient telegramClient); +} diff --git a/backend/src/main/java/com/launchly/bot/service/helper/BotModerationHelper.java b/backend/src/main/java/com/launchly/bot/service/helper/BotModerationHelper.java new file mode 100644 index 00000000..18a1f234 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/helper/BotModerationHelper.java @@ -0,0 +1,355 @@ +package com.launchly.bot.service.helper; + +import com.launchly.bot.constant.ModerationConstants; +import com.launchly.common.utils.MessageUtils; +import lombok.extern.slf4j.Slf4j; +import org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery; +import org.telegram.telegrambots.meta.api.methods.groupadministration.BanChatMember; +import org.telegram.telegrambots.meta.api.methods.groupadministration.RestrictChatMember; +import org.telegram.telegrambots.meta.api.methods.groupadministration.UnbanChatMember; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.meta.api.objects.ChatPermissions; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.message.Message; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; + +@Slf4j +public final class BotModerationHelper { + + private BotModerationHelper() {} + + public static boolean isForwarded(Message message) { + if (message == null) return false; + return message.getForwardOrigin() != null + || message.getForwardFrom() != null + || message.getForwardFromChat() != null + || message.getForwardSenderName() != null; + } + + public static boolean hasMediaContent(Message message) { + if (message == null) return false; + return message.hasPhoto() + || message.hasVideo() + || message.hasVoice() + || message.hasDocument() + || message.hasSticker() + || message.hasAnimation() + || message.hasAudio() + || message.hasVideoNote(); + } + + public static String extractMessageText(Message message) { + if (message == null) return null; + if (message.hasText()) return message.getText(); + if (message.getCaption() != null && !message.getCaption().isBlank()) return message.getCaption(); + return null; + } + + public static List extractUrls(String text) { + if (text == null || text.isBlank()) return Collections.emptyList(); + List urls = new ArrayList<>(); + Matcher matcher = ModerationConstants.URL_PATTERN.matcher(text); + while (matcher.find()) { + urls.add(matcher.group()); + } + return urls; + } + + public static boolean isAllowedLink(String url, List whitelist) { + if (url == null || whitelist == null || whitelist.isEmpty()) return false; + String lowerUrl = url.toLowerCase(); + for (String allowed : whitelist) { + if (lowerUrl.contains(allowed.toLowerCase().trim())) { + return true; + } + } + return false; + } + + public static List parseList(String raw) { + if (raw == null || raw.isBlank()) return Collections.emptyList(); + return Arrays.stream(raw.split("[,\\n]+")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .toList(); + } + + public static String findMatchedStopWord(String text, boolean defaultProfanityFilter, String customStopWords) { + if (text == null || text.isBlank()) return null; + String lowerText = text.toLowerCase(); + + if (defaultProfanityFilter) { + for (String scam : ModerationConstants.DEFAULT_SCAM_PATTERNS) { + if (lowerText.contains(scam.toLowerCase())) { + return scam; + } + } + for (String profanity : ModerationConstants.DEFAULT_PROFANITY_PATTERNS) { + if (lowerText.contains(profanity.toLowerCase())) { + return profanity; + } + } + } + + if (customStopWords != null && !customStopWords.isBlank()) { + List customWords = parseList(customStopWords); + for (String word : customWords) { + if (lowerText.contains(word.toLowerCase().trim())) { + return word.trim(); + } + } + } + + return null; + } + + public static String formatWarningMessage(String template, User from, MessageUtils messageUtils) { + String defaultUser = ModerationConstants.DEFAULT_USER_NAME; + String defaultTemplate = ModerationConstants.DEFAULT_WARNING_TEMPLATE; + + if (messageUtils != null) { + String resolvedUser = messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_DEFAULT_USER, ModerationConstants.DEFAULT_USER_NAME); + if (resolvedUser != null && !resolvedUser.isBlank()) { + defaultUser = resolvedUser; + } + String resolvedTemplate = messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_DEFAULT_WARNING_TEMPLATE, ModerationConstants.DEFAULT_WARNING_TEMPLATE); + if (resolvedTemplate != null && !resolvedTemplate.isBlank()) { + defaultTemplate = resolvedTemplate; + } + } + + String firstName = defaultUser; + String usernameOnly = defaultUser.toLowerCase(); + String mention = defaultUser; + + if (from != null) { + if (from.getFirstName() != null && !from.getFirstName().isBlank()) { + firstName = from.getFirstName(); + } + if (from.getUserName() != null && !from.getUserName().isBlank()) { + usernameOnly = from.getUserName(); + mention = "@" + from.getUserName(); + } else { + usernameOnly = firstName; + mention = firstName; + } + } + + String effectiveTemplate = (template != null && !template.isBlank()) + ? template + : defaultTemplate; + + return effectiveTemplate + .replace("{first_name}", firstName) + .replace("{name}", firstName) + .replace("@{username}", mention) + .replace("{username}", usernameOnly) + .replace("{user}", mention); + } + + public static String formatWarningMessage(String template, User from) { + return formatWarningMessage(template, from, null); + } + + public static void deleteMessageSafe(TelegramClient client, Long chatId, Integer messageId) { + if (client == null || chatId == null || messageId == null) return; + try { + client.execute(DeleteMessage.builder() + .chatId(String.valueOf(chatId)) + .messageId(messageId) + .build()); + } catch (Exception e) { + log.warn("Failed to delete messageId={} in chatId={}: {}", messageId, chatId, e.getMessage()); + } + } + + public static void sendSelfDestructWarning(TelegramClient client, + ScheduledExecutorService executor, + Long chatId, + User from, + String template, + int ttlSeconds, + MessageUtils messageUtils) { + if (client == null || chatId == null) return; + + String warnText = formatWarningMessage(template, from, messageUtils); + int effectiveTtl = ttlSeconds > 0 ? ttlSeconds : ModerationConstants.DEFAULT_WARN_TTL_SECONDS; + + try { + Message warnMsg = client.execute(SendMessage.builder() + .chatId(String.valueOf(chatId)) + .text(warnText) + .build()); + + if (warnMsg != null && warnMsg.getMessageId() != null && executor != null) { + Integer warnMsgId = warnMsg.getMessageId(); + executor.schedule(() -> { + try { + client.execute(DeleteMessage.builder() + .chatId(String.valueOf(chatId)) + .messageId(warnMsgId) + .build()); + } catch (Exception e) { + log.debug("Auto-delete warning message {} expired or failed: {}", warnMsgId, e.getMessage()); + } + }, effectiveTtl, TimeUnit.SECONDS); + } + } catch (Exception e) { + log.warn("Failed to send warning message in chatId={}: {}", chatId, e.getMessage()); + } + } + + public static void sendSelfDestructWarning(TelegramClient client, + ScheduledExecutorService executor, + Long chatId, + User from, + String template, + int ttlSeconds) { + sendSelfDestructWarning(client, executor, chatId, from, template, ttlSeconds, null); + } + + public static void muteUser(TelegramClient client, Long chatId, Long userId, int durationSeconds) { + if (client == null || chatId == null || userId == null) return; + int effectiveDuration = durationSeconds > 0 ? durationSeconds : ModerationConstants.DEFAULT_MUTE_DURATION_SECONDS; + + try { + ChatPermissions permissions = ChatPermissions.builder() + .canSendMessages(false) + .canSendAudios(false) + .canSendDocuments(false) + .canSendPhotos(false) + .canSendVideos(false) + .canSendVideoNotes(false) + .canSendVoiceNotes(false) + .canSendPolls(false) + .canSendOtherMessages(false) + .canAddWebPagePreviews(false) + .build(); + + int untilDate = (int) (System.currentTimeMillis() / ModerationConstants.MILLIS_PER_SECOND + effectiveDuration); + + client.execute(RestrictChatMember.builder() + .chatId(String.valueOf(chatId)) + .userId(userId) + .permissions(permissions) + .untilDate(untilDate) + .build()); + log.info("Muted userId={} in chatId={} for {}s", userId, chatId, effectiveDuration); + } catch (Exception e) { + log.warn("Failed to mute userId={} in chatId={}: {}", userId, chatId, e.getMessage()); + } + } + + public static void kickUser(TelegramClient client, Long chatId, Long userId) { + if (client == null || chatId == null || userId == null) return; + try { + client.execute(BanChatMember.builder() + .chatId(String.valueOf(chatId)) + .userId(userId) + .build()); + client.execute(UnbanChatMember.builder() + .chatId(String.valueOf(chatId)) + .userId(userId) + .onlyIfBanned(true) + .build()); + log.info("Kicked userId={} from chatId={}", userId, chatId); + } catch (Exception e) { + log.warn("Failed to kick userId={} from chatId={}: {}", userId, chatId, e.getMessage()); + } + } + + public static void unmuteUser(TelegramClient client, Long chatId, Long userId) { + if (client == null || chatId == null || userId == null) return; + try { + ChatPermissions permissions = ChatPermissions.builder() + .canSendMessages(true) + .canSendAudios(true) + .canSendDocuments(true) + .canSendPhotos(true) + .canSendVideos(true) + .canSendVideoNotes(true) + .canSendVoiceNotes(true) + .canSendPolls(true) + .canSendOtherMessages(true) + .canAddWebPagePreviews(true) + .build(); + + client.execute(RestrictChatMember.builder() + .chatId(String.valueOf(chatId)) + .userId(userId) + .permissions(permissions) + .useIndependentChatPermissions(true) + .build()); + log.info("Unmuted userId={} in chatId={}", userId, chatId); + } catch (Exception e) { + log.warn("Failed to unmute userId={} in chatId={}: {}", userId, chatId, e.getMessage()); + } + } + + public static void answerCallbackQuery(TelegramClient client, String callbackQueryId, String text, boolean showAlert) { + if (client == null || callbackQueryId == null) return; + try { + client.execute(AnswerCallbackQuery.builder() + .callbackQueryId(callbackQueryId) + .text(text) + .showAlert(showAlert) + .build()); + } catch (Exception e) { + log.debug("Failed to answer callback query {}: {}", callbackQueryId, e.getMessage()); + } + } + + public static String formatCaptchaMessage(String template, User from, int timeoutSeconds, MessageUtils messageUtils) { + String defaultUser = ModerationConstants.DEFAULT_USER_NAME; + String defaultTemplate = ModerationConstants.DEFAULT_CAPTCHA_MESSAGE_TEMPLATE; + + if (messageUtils != null) { + String resolvedUser = messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_DEFAULT_USER, ModerationConstants.DEFAULT_USER_NAME); + if (resolvedUser != null && !resolvedUser.isBlank()) { + defaultUser = resolvedUser; + } + String resolvedTemplate = messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_CAPTCHA_DEFAULT_MESSAGE, ModerationConstants.DEFAULT_CAPTCHA_MESSAGE_TEMPLATE); + if (resolvedTemplate != null && !resolvedTemplate.isBlank()) { + defaultTemplate = resolvedTemplate; + } + } + + String firstName = defaultUser; + String usernameOnly = defaultUser.toLowerCase(); + String mention = defaultUser; + + if (from != null) { + if (from.getFirstName() != null && !from.getFirstName().isBlank()) { + firstName = from.getFirstName(); + } + if (from.getUserName() != null && !from.getUserName().isBlank()) { + usernameOnly = from.getUserName(); + mention = "@" + from.getUserName(); + } else { + usernameOnly = firstName; + mention = firstName; + } + } + + String effectiveTemplate = (template != null && !template.isBlank()) + ? template + : defaultTemplate; + + return effectiveTemplate + .replace("{first_name}", firstName) + .replace("{name}", firstName) + .replace("@{username}", mention) + .replace("{username}", usernameOnly) + .replace("{user}", mention) + .replace("{timeout}", String.valueOf(timeoutSeconds)) + .replace("{timeout_seconds}", String.valueOf(timeoutSeconds)); + } +} diff --git a/backend/src/main/java/com/launchly/bot/service/impl/BotDialogStateServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/BotDialogStateServiceImpl.java index dc021f99..a33bb34f 100644 --- a/backend/src/main/java/com/launchly/bot/service/impl/BotDialogStateServiceImpl.java +++ b/backend/src/main/java/com/launchly/bot/service/impl/BotDialogStateServiceImpl.java @@ -1,14 +1,20 @@ package com.launchly.bot.service.impl; +import com.launchly.bot.entity.BotUser; import com.launchly.bot.service.BotDialogStateService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor public class BotDialogStateServiceImpl implements BotDialogStateService { @@ -20,6 +26,7 @@ public class BotDialogStateServiceImpl implements BotDialogStateService { private static final String CAMPAIGN_PREFIX = "launchly:bot:campaign:"; private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; @Override public void setCurrentNodeId(Long botId, Long telegramUserId, String nodeId) { @@ -120,4 +127,36 @@ public void clearSession(Long botId, Long telegramUserId) { redisTemplate.delete(dataKey); redisTemplate.delete(campaignKey); } + + @Override + public boolean isAutomationPaused(BotUser botUser) { + if (botUser == null) return false; + String metadata = botUser.getMetadata(); + if (metadata == null || metadata.isBlank() || "{}".equals(metadata)) return false; + try { + Map meta = objectMapper.readValue(metadata, new TypeReference>() {}); + if (meta != null && Boolean.TRUE.equals(meta.get("paused"))) { + Object pausedUntilObj = meta.get("pausedUntil"); + if (pausedUntilObj instanceof Number) { + long pausedUntil = ((Number) pausedUntilObj).longValue(); + if (System.currentTimeMillis() > pausedUntil) { + return false; + } + } else if (pausedUntilObj instanceof String) { + try { + long pausedUntil = Long.parseLong((String) pausedUntilObj); + if (System.currentTimeMillis() > pausedUntil) { + return false; + } + } catch (NumberFormatException e) { + log.warn("Failed to parse pausedUntil timestamp: {}", pausedUntilObj); + } + } + return true; + } + } catch (Exception e) { + log.warn("Failed to check if automation is paused for user {}: {}", botUser.getId(), e.getMessage()); + } + return false; + } } diff --git a/backend/src/main/java/com/launchly/bot/service/impl/BotLifecycleServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/BotLifecycleServiceImpl.java new file mode 100644 index 00000000..a4cc5508 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/impl/BotLifecycleServiceImpl.java @@ -0,0 +1,213 @@ +package com.launchly.bot.service.impl; + +import com.launchly.common.constant.CacheConstants; +import com.launchly.bot.constant.BotConstants; +import com.launchly.bot.dto.response.BotResponse; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.mapper.BotResponseFactory; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.service.BotLifecycleService; +import com.launchly.bot.telegram.TelegramBotManager; +import com.launchly.bot.validator.BotAccessValidator; +import com.launchly.common.exception.AppException; +import com.launchly.common.utils.EncryptionUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.launchly.bot.entity.FlowSchema; +import com.launchly.bot.repository.FlowSchemaRepository; +import org.springframework.data.redis.core.StringRedisTemplate; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BotLifecycleServiceImpl implements BotLifecycleService { + + private final BotRepository botRepository; + private final TelegramBotManager telegramBotManager; + private final BotAccessValidator botAccessValidator; + private final EncryptionUtil encryptionUtil; + private final BotResponseFactory botResponseFactory; + private final FlowSchemaRepository flowSchemaRepository; + private final StringRedisTemplate redisTemplate; + + @Override + @CacheEvict(value = CacheConstants.BOTS, key = "#userId") + public BotResponse startBot(Long id, Long userId) { + Bot bot = botAccessValidator.getBotWithAccess(id, userId); + botAccessValidator.validateWriteAccess(bot, userId); + + if (bot.isActive()) { + throw new AppException(HttpStatus.CONFLICT, "bot.error.already_running"); + } + + String decryptedToken = encryptionUtil.decrypt(bot.getTelegramToken()); + if (BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(decryptedToken)) { + throw new AppException(HttpStatus.BAD_REQUEST, "bot.error.token_required_to_start"); + } + + List activeBotsList = botRepository.findAllByActiveTrue(); + for (Bot activeBot : activeBotsList) { + if (!activeBot.getId().equals(bot.getId())) { + String activeToken = encryptionUtil.decrypt(activeBot.getTelegramToken()); + if (decryptedToken.equals(activeToken)) { + telegramBotManager.unregisterBot(activeBot.getId()); + activeBot.setActive(false); + botRepository.save(activeBot); + log.info("Automatically deactivated bot id={} ('{}') because its token was assigned to bot id={} ('{}')", + activeBot.getId(), activeBot.getName(), bot.getId(), bot.getName()); + } + } + } + + telegramBotManager.registerBot(bot); + + try { + bot.setActive(true); + bot.setRunsCount(bot.getRunsCount() + 1); + bot = botRepository.save(bot); + + FlowSchema schema = flowSchemaRepository.findByBotId(bot.getId()).orElse(null); + if (schema != null) { + if (schema.getPublishedNodes() == null || schema.getPublishedNodes().isBlank() || "[]".equals(schema.getPublishedNodes().trim())) { + schema.setPublishedNodes(schema.getNodes()); + schema.setPublishedEdges(schema.getEdges()); + flowSchemaRepository.save(schema); + } + redisTemplate.delete("launchly:bot:schema:" + id); + } + } catch (Exception e) { + try { + telegramBotManager.unregisterBot(bot.getId()); + } catch (Exception ex) { + log.warn("Failed to unregister bot on start rollback: {}", ex.getMessage()); + } + throw e; + } + + return botResponseFactory.toBotResponseWithStats(bot); + } + + @Override + @Transactional + @Caching(evict = { + @CacheEvict(value = CacheConstants.BOTS, key = "#userId"), + @CacheEvict(value = CacheConstants.FLOW_SCHEMAS, key = "#id") + }) + public BotResponse publishBot(Long id, Long userId) { + Bot bot = botAccessValidator.getBotWithAccess(id, userId); + botAccessValidator.validateWriteAccess(bot, userId); + + bot.setRunsCount(bot.getRunsCount() + 1); + bot.setUpdatedAt(LocalDateTime.now()); + + FlowSchema schema = flowSchemaRepository.findByBotId(bot.getId()).orElse(null); + if (schema != null) { + schema.setPublishedNodes(schema.getNodes()); + schema.setPublishedEdges(schema.getEdges()); + schema.setVersion(schema.getVersion() + 1); + flowSchemaRepository.save(schema); + redisTemplate.delete("launchly:bot:schema:" + id); + log.info("Published flow schema version {} for botId={}", schema.getVersion(), id); + } + + if (!bot.isActive()) { + boolean hasRealToken = false; + try { + if (bot.getTelegramToken() != null && !bot.getTelegramToken().isBlank()) { + String decrypted = encryptionUtil.decrypt(bot.getTelegramToken()); + if (decrypted != null && !decrypted.isBlank() && + !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(decrypted)) { + hasRealToken = true; + } + } + } catch (Exception e) { + log.warn("Failed to decrypt bot token during publish: {}", e.getMessage()); + } + + if (hasRealToken) { + try { + telegramBotManager.registerBot(bot); + bot.setActive(true); + } catch (Exception e) { + log.error("Failed to register bot on publish: {}", e.getMessage(), e); + } + } + } + + bot = botRepository.save(bot); + return botResponseFactory.toBotResponseWithStats(bot); + } + + @Override + @CacheEvict(value = CacheConstants.BOTS, key = "#userId") + public BotResponse stopBot(Long id, Long userId) { + Bot bot = botAccessValidator.getBotWithAccess(id, userId); + botAccessValidator.validateWriteAccess(bot, userId); + + if (!bot.isActive()) { + throw new AppException(HttpStatus.CONFLICT, "bot.error.not_running"); + } + + telegramBotManager.unregisterBot(bot.getId()); + + try { + bot.setActive(false); + bot = botRepository.save(bot); + } catch (Exception e) { + try { + telegramBotManager.registerBot(bot); + } catch (Exception ex) { + log.error("Failed to re-register bot {} after stop failure: {}", bot.getId(), ex.getMessage(), ex); + } + throw e; + } + + return botResponseFactory.toBotResponseWithStats(bot); + } + + @Override + public void releaseTokenFromOtherBots(String token, Long userId, Long currentBotId) { + if (token == null || BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(token)) { + return; + } + + List userBots = botRepository.findAllByUserId(userId); + for (Bot otherBot : userBots) { + if (!otherBot.getId().equals(currentBotId)) { + try { + String decrypted = encryptionUtil.decrypt(otherBot.getTelegramToken()); + if (token.equals(decrypted)) { + if (otherBot.isActive()) { + telegramBotManager.unregisterBot(otherBot.getId()); + otherBot.setActive(false); + } + otherBot.setTelegramToken(encryptionUtil.encrypt(BotConstants.DUMMY_TOKEN_PLACEHOLDER)); + otherBot.setUsername(null); + botRepository.save(otherBot); + log.info("Reassigned token to bot id={}. Automatically reset bot id={} ('{}') to Without bot (inactive)", + currentBotId, otherBot.getId(), otherBot.getName()); + } + } catch (Exception e) { + log.error("Failed to release token from other bot id={}: {}", otherBot.getId(), e.getMessage()); + } + } + } + } + + @Override + public void registerBot(Bot bot) { + telegramBotManager.registerBot(bot); + } + + @Override + public void unregisterBot(Long botId) { + telegramBotManager.unregisterBot(botId); + } +} diff --git a/backend/src/main/java/com/launchly/bot/service/impl/BotModerationServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/BotModerationServiceImpl.java new file mode 100644 index 00000000..2884d486 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/impl/BotModerationServiceImpl.java @@ -0,0 +1,662 @@ +package com.launchly.bot.service.impl; + +import com.launchly.bot.constant.ModerationConstants; +import com.launchly.bot.dto.moderation.BotModerationRuleDto; +import com.launchly.bot.dto.moderation.TestModerationRequest; +import com.launchly.bot.dto.moderation.TestModerationResponse; +import com.launchly.bot.dto.moderation.UpdateBotModerationRuleRequest; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotModerationRule; +import com.launchly.bot.entity.CaptchaMode; +import com.launchly.bot.entity.FlowSchema; +import com.launchly.bot.entity.MediaMode; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.entity.ViolationAction; +import com.launchly.bot.repository.BotModerationRuleRepository; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.repository.FlowSchemaRepository; +import com.launchly.bot.service.BotModerationService; +import com.launchly.bot.service.helper.BotModerationHelper; +import com.launchly.common.exception.AppException; +import com.launchly.common.utils.MessageUtils; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberUpdated; +import org.telegram.telegrambots.meta.api.objects.message.Message; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BotModerationServiceImpl implements BotModerationService { + + private final BotModerationRuleRepository ruleRepository; + private final BotRepository botRepository; + private final FlowSchemaRepository flowSchemaRepository; + private final ObjectMapper objectMapper; + private final MessageUtils messageUtils; + private final ScheduledExecutorService scheduledExecutor; + private final ConcurrentMap pendingCaptchas = new ConcurrentHashMap<>(); + + @Override + @Transactional(readOnly = true) + public BotModerationRuleDto getModerationSettings(Long botId) { + BotModerationRule rule = ruleRepository.findByBotIdAndChatId(botId, ModerationConstants.GLOBAL_CHAT_ID) + .orElseGet(() -> createDefaultRule(botId)); + return toDto(rule); + } + + @Override + @Transactional + public BotModerationRuleDto updateModerationSettings(Long botId, UpdateBotModerationRuleRequest request) { + Bot bot = botRepository.findById(botId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.not_found")); + + String chatId = (request.getChatId() != null && !request.getChatId().isBlank()) + ? request.getChatId() + : ModerationConstants.GLOBAL_CHAT_ID; + + BotModerationRule rule = ruleRepository.findByBotIdAndChatId(botId, chatId) + .orElseGet(() -> BotModerationRule.builder().bot(bot).chatId(chatId).build()); + + rule.setThreadId(request.getThreadId()); + rule.setEnabled(request.isEnabled()); + rule.setAntiForwardEnabled(request.isAntiForwardEnabled()); + rule.setAntiLinkEnabled(request.isAntiLinkEnabled()); + rule.setAllowedLinks(request.getAllowedLinks()); + rule.setStopWords(request.getStopWords()); + rule.setDefaultProfanityFilter(request.isDefaultProfanityFilter()); + rule.setMediaMode(request.getMediaMode() != null ? request.getMediaMode() : MediaMode.ALL); + rule.setActionOnViolation(request.getActionOnViolation() != null ? request.getActionOnViolation() : ViolationAction.DELETE_AND_WARN); + rule.setCaptchaEnabled(request.isCaptchaEnabled()); + rule.setCaptchaMode(request.getCaptchaMode() != null ? request.getCaptchaMode() : CaptchaMode.BUTTON); + rule.setCaptchaTimeoutSeconds(request.getCaptchaTimeoutSeconds() != null && request.getCaptchaTimeoutSeconds() > 0 ? request.getCaptchaTimeoutSeconds() : 60); + rule.setCaptchaMessageTemplate(request.getCaptchaMessageTemplate()); + + if (request.getWarningTemplate() != null) { + rule.setWarningTemplate(request.getWarningTemplate()); + } + if (request.getWarnTtlSeconds() != null && request.getWarnTtlSeconds() > 0) { + rule.setWarnTtlSeconds(request.getWarnTtlSeconds()); + } + + BotModerationRule saved = ruleRepository.save(rule); + return toDto(saved); + } + + @Override + public boolean processUpdateModeration(Long botId, Update update, TelegramClient client) { + if (client == null || update == null) { + return false; + } + + if (update.hasCallbackQuery()) { + CallbackQuery cb = update.getCallbackQuery(); + if (cb.getData() != null && cb.getData().startsWith(ModerationConstants.CB_CAPTCHA_PREFIX)) { + return handleCaptchaCallback(botId, cb, client); + } + } + + if (update.hasMessage()) { + Message message = update.getMessage(); + if (message.getNewChatMembers() != null && !message.getNewChatMembers().isEmpty()) { + return handleNewChatMembers(botId, message, client); + } + + Long chatId = message.getChatId(); + if (chatId == null) { + return false; + } + + BotModerationRule rule = resolveEffectiveRule(botId, String.valueOf(chatId), message.getMessageThreadId()); + if (rule == null || !rule.isEnabled()) { + return false; + } + + List reasons = evaluateViolations(message, rule); + if (reasons.isEmpty()) { + return false; + } + + log.info("Moderation violation detected in botId={} chatId={} userId={} reasons={}", + botId, chatId, message.getFrom() != null ? message.getFrom().getId() : null, reasons); + + executeSanctions(client, message, rule); + return true; + } + + if (update.hasChatMember()) { + ChatMemberUpdated cmu = update.getChatMember(); + if (cmu != null && cmu.getNewChatMember() != null + && "member".equalsIgnoreCase(cmu.getNewChatMember().getStatus()) + && (cmu.getOldChatMember() == null || !"member".equalsIgnoreCase(cmu.getOldChatMember().getStatus()))) { + return handleChatMemberUpdated(botId, cmu, client); + } + } + + return false; + } + + @Override + @Transactional(readOnly = true) + public TestModerationResponse testModeration(Long botId, TestModerationRequest request) { + BotModerationRule rule = ruleRepository.findByBotIdAndChatId(botId, ModerationConstants.GLOBAL_CHAT_ID) + .orElseGet(() -> createDefaultRule(botId)); + + List reasons = new ArrayList<>(); + String matchedStopWord = null; + + if (request.isForwarded() && rule.isAntiForwardEnabled()) { + reasons.add(resolveReason(ModerationConstants.MSG_KEY_REASON_ANTI_FORWARD, "Anti-Forward")); + } + + if (rule.getMediaMode() == MediaMode.TEXT_ONLY && request.isHasMedia()) { + reasons.add(resolveReason(ModerationConstants.MSG_KEY_REASON_TEXT_ONLY, "Media Mode: Text only")); + } else if (rule.getMediaMode() == MediaMode.MEDIA_ONLY && !request.isHasMedia()) { + reasons.add(resolveReason(ModerationConstants.MSG_KEY_REASON_MEDIA_ONLY, "Media Mode: Media only")); + } + + String text = request.getText() != null ? request.getText() : ""; + + if (rule.isAntiLinkEnabled()) { + List links = BotModerationHelper.extractUrls(text); + List whitelist = BotModerationHelper.parseList(rule.getAllowedLinks()); + for (String link : links) { + if (!BotModerationHelper.isAllowedLink(link, whitelist)) { + reasons.add(resolveReason(ModerationConstants.MSG_KEY_REASON_ANTI_LINK, "Anti-Link: " + link, link)); + break; + } + } + } + + matchedStopWord = BotModerationHelper.findMatchedStopWord(text, rule.isDefaultProfanityFilter(), rule.getStopWords()); + if (matchedStopWord != null) { + reasons.add(resolveReason(ModerationConstants.MSG_KEY_REASON_STOP_WORD, "Stop-Words: " + matchedStopWord, matchedStopWord)); + } + + return TestModerationResponse.builder() + .violated(!reasons.isEmpty()) + .reasons(reasons) + .matchedStopWord(matchedStopWord) + .build(); + } + + private String resolveReason(String code, String fallback, Object... args) { + if (messageUtils == null) { + return fallback; + } + String resolved = messageUtils.getMessageWithDefault(code, fallback, args); + return (resolved != null && !resolved.isBlank()) ? resolved : fallback; + } + + private BotModerationRule resolveEffectiveRule(Long botId, String chatId, Integer threadId) { + if (flowSchemaRepository != null && objectMapper != null) { + try { + Optional schemaOpt = flowSchemaRepository.findByBotId(botId); + if (schemaOpt.isPresent()) { + FlowSchema schema = schemaOpt.get(); + + FlowNode modNode = null; + if (schema.getNodes() != null && !schema.getNodes().isBlank() && !"[]".equals(schema.getNodes().trim())) { + modNode = findModerationNode(schema.getNodes()); + } + if (modNode == null) { + modNode = findModerationNode(schema.getEffectivePublishedNodes()); + } + + if (modNode != null) { + Map data = modNode.data() != null ? modNode.data() : Collections.emptyMap(); + boolean enabled = parseBoolean(data.getOrDefault("isEnabled", data.getOrDefault("enabled", data.getOrDefault("isActive", true))), true); + if (!enabled) { + log.debug("Moderation node is disabled for bot {}", botId); + return null; + } + return buildRuleFromNodeData(chatId, data); + } + } + } catch (Exception e) { + log.warn("Failed to extract MODERATION node rule from flow schema for bot {}: {}", botId, e.getMessage()); + } + } + + List rules = ruleRepository.findAllByBotIdAndEnabledTrue(botId); + if (!rules.isEmpty()) { + BotModerationRule matching = findMatchingRule(rules, chatId, threadId); + if (matching != null && matching.isEnabled()) { + return matching; + } + } + + return null; + } + + private FlowNode findModerationNode(String nodesJson) { + if (nodesJson == null || nodesJson.isBlank() || "[]".equals(nodesJson.trim())) { + return null; + } + try { + List nodes = objectMapper.readValue(nodesJson, new TypeReference>() {}); + for (FlowNode node : nodes) { + if (node.type() == NodeType.MODERATION) { + return node; + } + } + } catch (Exception e) { + log.warn("Failed to parse nodes JSON for moderation: {}", e.getMessage()); + } + return null; + } + + private boolean parseBoolean(Object value, boolean defaultValue) { + if (value == null) return defaultValue; + if (value instanceof Boolean b) return b; + return Boolean.parseBoolean(value.toString()); + } + + private int parseInteger(Object value, int defaultValue) { + if (value == null) return defaultValue; + if (value instanceof Number n) return n.intValue(); + try { + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return defaultValue; + } + } + + private BotModerationRule buildRuleFromNodeData(String chatId, Map data) { + boolean antiForward = parseBoolean(data.get("antiForwardEnabled"), true); + boolean antiLink = parseBoolean(data.get("antiLinkEnabled"), false); + boolean filterProfanity = parseBoolean(data.get("filterProfanity"), true); + String mediaModeStr = (String) data.getOrDefault("mediaMode", "ALL"); + String actionStr = (String) data.getOrDefault("actionOnViolation", "DELETE_AND_WARN"); + String warningTemplate = (String) data.getOrDefault("warningTemplate", ModerationConstants.DEFAULT_WARNING_TEMPLATE); + int warnTtl = parseInteger(data.get("warnAutoDeleteSeconds"), ModerationConstants.DEFAULT_WARN_AUTO_DELETE_SECONDS); + + boolean captchaEnabled = parseBoolean(data.get("captchaEnabled"), false); + String captchaModeStr = (String) data.getOrDefault("captchaMode", "BUTTON"); + int captchaTimeout = parseInteger(data.get("captchaTimeoutSeconds"), ModerationConstants.DEFAULT_CAPTCHA_TIMEOUT_SECONDS); + String captchaTemplate = (String) data.get("captchaMessageTemplate"); + + String stopWordsStr = extractJoinedString(data.get("stopWords")); + String allowedLinksStr = extractJoinedString(data.get("whitelistedDomains")); + + MediaMode mediaMode = parseEnum(mediaModeStr, MediaMode.class, MediaMode.ALL); + ViolationAction action = parseEnum(actionStr, ViolationAction.class, ViolationAction.DELETE_AND_WARN); + CaptchaMode captchaMode = parseEnum(captchaModeStr, CaptchaMode.class, CaptchaMode.BUTTON); + + return BotModerationRule.builder() + .chatId(chatId) + .enabled(true) + .antiForwardEnabled(antiForward) + .antiLinkEnabled(antiLink) + .allowedLinks(allowedLinksStr) + .stopWords(stopWordsStr) + .defaultProfanityFilter(filterProfanity) + .mediaMode(mediaMode) + .actionOnViolation(action) + .warningTemplate(warningTemplate) + .warnTtlSeconds(warnTtl) + .captchaEnabled(captchaEnabled) + .captchaMode(captchaMode) + .captchaTimeoutSeconds(captchaTimeout) + .captchaMessageTemplate(captchaTemplate) + .build(); + } + + private boolean handleCaptchaCallback(Long botId, CallbackQuery cb, TelegramClient client) { + String data = cb.getData(); + if (data == null || !data.startsWith(ModerationConstants.CB_CAPTCHA_PREFIX)) { + return false; + } + + String[] parts = data.split(":"); + if (parts.length < 3) { + return false; + } + + String type = parts[1]; + Long targetUserId; + try { + targetUserId = Long.parseLong(parts[2]); + } catch (NumberFormatException e) { + return false; + } + + User from = cb.getFrom(); + if (from == null) { + return false; + } + + if (!from.getId().equals(targetUserId)) { + String alert = messageUtils != null + ? messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_CAPTCHA_NOT_FOR_YOU, ModerationConstants.DEFAULT_CAPTCHA_NOT_FOR_YOU) + : ModerationConstants.DEFAULT_CAPTCHA_NOT_FOR_YOU; + BotModerationHelper.answerCallbackQuery(client, cb.getId(), alert, true); + return true; + } + + Long chatId = null; + Integer messageId = null; + if (cb.getMessage() instanceof Message origMsg) { + chatId = origMsg.getChatId(); + messageId = origMsg.getMessageId(); + } + + boolean passed = false; + if ("btn".equalsIgnoreCase(type)) { + passed = true; + } else if ("math".equalsIgnoreCase(type) && parts.length >= 5) { + String selected = parts[3]; + String correct = parts[4]; + passed = selected.equals(correct); + } + + String pendingKey = chatId + ":" + targetUserId; + if (passed) { + pendingCaptchas.remove(pendingKey); + if (chatId != null) { + BotModerationHelper.unmuteUser(client, chatId, targetUserId); + if (messageId != null) { + BotModerationHelper.deleteMessageSafe(client, chatId, messageId); + } + } + String success = messageUtils != null + ? messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_CAPTCHA_SUCCESS, ModerationConstants.DEFAULT_CAPTCHA_SUCCESS) + : ModerationConstants.DEFAULT_CAPTCHA_SUCCESS; + BotModerationHelper.answerCallbackQuery(client, cb.getId(), success, false); + } else { + String fail = messageUtils != null + ? messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_CAPTCHA_FAIL, ModerationConstants.DEFAULT_CAPTCHA_FAIL) + : ModerationConstants.DEFAULT_CAPTCHA_FAIL; + BotModerationHelper.answerCallbackQuery(client, cb.getId(), fail, true); + } + return true; + } + + private boolean handleNewChatMembers(Long botId, Message message, TelegramClient client) { + Long chatId = message.getChatId(); + if (chatId == null) return false; + + BotModerationRule rule = resolveEffectiveRule(botId, String.valueOf(chatId), message.getMessageThreadId()); + if (rule == null || !rule.isEnabled() || !rule.isCaptchaEnabled()) { + return false; + } + + for (User user : message.getNewChatMembers()) { + if (user.getIsBot() != null && user.getIsBot()) { + continue; + } + initiateCaptchaChallenge(client, chatId, user, rule); + } + return true; + } + + private boolean handleChatMemberUpdated(Long botId, ChatMemberUpdated cmu, TelegramClient client) { + if (cmu == null || cmu.getChat() == null || cmu.getNewChatMember() == null) { + return false; + } + Long chatId = cmu.getChat().getId(); + User user = cmu.getNewChatMember().getUser(); + if (chatId == null || user == null || (user.getIsBot() != null && user.getIsBot())) { + return false; + } + + BotModerationRule rule = resolveEffectiveRule(botId, String.valueOf(chatId), null); + if (rule == null || !rule.isEnabled() || !rule.isCaptchaEnabled()) { + return false; + } + + initiateCaptchaChallenge(client, chatId, user, rule); + return true; + } + + private void initiateCaptchaChallenge(TelegramClient client, Long chatId, User user, BotModerationRule rule) { + int timeout = rule.getCaptchaTimeoutSeconds() != null && rule.getCaptchaTimeoutSeconds() > 0 + ? rule.getCaptchaTimeoutSeconds() + : ModerationConstants.DEFAULT_CAPTCHA_TIMEOUT_SECONDS; + + BotModerationHelper.muteUser(client, chatId, user.getId(), timeout * 2); + + String text = BotModerationHelper.formatCaptchaMessage(rule.getCaptchaMessageTemplate(), user, timeout, messageUtils); + + InlineKeyboardMarkup markup; + if (rule.getCaptchaMode() == CaptchaMode.MATH) { + markup = buildMathKeyboard(user.getId()); + } else { + String btnText = messageUtils != null + ? messageUtils.getMessageWithDefault(ModerationConstants.MSG_KEY_CAPTCHA_BUTTON_HUMAN, ModerationConstants.DEFAULT_CAPTCHA_BUTTON_LABEL) + : ModerationConstants.DEFAULT_CAPTCHA_BUTTON_LABEL; + InlineKeyboardButton button = InlineKeyboardButton.builder() + .text(btnText) + .callbackData(ModerationConstants.CB_CAPTCHA_PREFIX + "btn:" + user.getId()) + .build(); + markup = InlineKeyboardMarkup.builder() + .keyboardRow(new InlineKeyboardRow(button)) + .build(); + } + + try { + Message challengeMsg = client.execute(SendMessage.builder() + .chatId(String.valueOf(chatId)) + .text(text) + .replyMarkup(markup) + .build()); + + if (challengeMsg != null && challengeMsg.getMessageId() != null) { + Integer challengeMsgId = challengeMsg.getMessageId(); + String key = chatId + ":" + user.getId(); + pendingCaptchas.put(key, challengeMsgId); + + scheduledExecutor.schedule(() -> { + if (pendingCaptchas.remove(key) != null) { + log.info("Captcha challenge expired for userId={} in chatId={}, kicking user", user.getId(), chatId); + BotModerationHelper.kickUser(client, chatId, user.getId()); + BotModerationHelper.deleteMessageSafe(client, chatId, challengeMsgId); + } + }, timeout, TimeUnit.SECONDS); + } + } catch (Exception e) { + log.warn("Failed to send captcha challenge in chatId={} for userId={}: {}", chatId, user.getId(), e.getMessage()); + } + } + + private InlineKeyboardMarkup buildMathKeyboard(Long userId) { + Random random = new Random(); + int a = random.nextInt(9) + 1; + int b = random.nextInt(9) + 1; + int correct = a + b; + + Set options = new LinkedHashSet<>(); + options.add(correct); + while (options.size() < 4) { + int distractor = correct + (random.nextInt(7) - 3); + if (distractor > 0 && distractor != correct) { + options.add(distractor); + } + } + + List shuffled = new ArrayList<>(options); + Collections.shuffle(shuffled); + + List buttons = new ArrayList<>(); + for (Integer opt : shuffled) { + buttons.add(InlineKeyboardButton.builder() + .text(String.valueOf(opt)) + .callbackData(ModerationConstants.CB_CAPTCHA_PREFIX + "math:" + userId + ":" + opt + ":" + correct) + .build()); + } + + return InlineKeyboardMarkup.builder() + .keyboardRow(new InlineKeyboardRow(buttons)) + .build(); + } + + private String extractJoinedString(Object obj) { + if (obj instanceof List list) { + return String.join(", ", list.stream().map(Object::toString).toList()); + } else if (obj instanceof String s) { + return s; + } + return ""; + } + + private > E parseEnum(String value, Class enumClass, E defaultValue) { + if (value == null || value.isBlank()) return defaultValue; + try { + return Enum.valueOf(enumClass, value.trim().toUpperCase()); + } catch (Exception ignored) { + return defaultValue; + } + } + + private List evaluateViolations(Message message, BotModerationRule rule) { + List reasons = new ArrayList<>(); + + if (rule.isAntiForwardEnabled() && BotModerationHelper.isForwarded(message)) { + reasons.add(ModerationConstants.CODE_ANTI_FORWARD); + } + + boolean hasMedia = BotModerationHelper.hasMediaContent(message); + if (rule.getMediaMode() == MediaMode.TEXT_ONLY && hasMedia) { + reasons.add(ModerationConstants.CODE_TEXT_ONLY); + } else if (rule.getMediaMode() == MediaMode.MEDIA_ONLY && !hasMedia) { + reasons.add(ModerationConstants.CODE_MEDIA_ONLY); + } + + String text = BotModerationHelper.extractMessageText(message); + + if (rule.isAntiLinkEnabled() && text != null) { + List links = BotModerationHelper.extractUrls(text); + List whitelist = BotModerationHelper.parseList(rule.getAllowedLinks()); + for (String link : links) { + if (!BotModerationHelper.isAllowedLink(link, whitelist)) { + reasons.add(ModerationConstants.CODE_ANTI_LINK_PREFIX + link); + break; + } + } + } + + if (text != null) { + String matched = BotModerationHelper.findMatchedStopWord(text, rule.isDefaultProfanityFilter(), rule.getStopWords()); + if (matched != null) { + reasons.add(ModerationConstants.CODE_STOP_WORD_PREFIX + matched); + } + } + + return reasons; + } + + private void executeSanctions(TelegramClient client, Message message, BotModerationRule rule) { + Long chatId = message.getChatId(); + Integer messageId = message.getMessageId(); + User from = message.getFrom(); + Long userId = from != null ? from.getId() : null; + + BotModerationHelper.deleteMessageSafe(client, chatId, messageId); + + ViolationAction action = rule.getActionOnViolation(); + if (action == ViolationAction.DELETE_AND_WARN + || action == ViolationAction.DELETE_AND_MUTE + || action == ViolationAction.DELETE_AND_KICK) { + BotModerationHelper.sendSelfDestructWarning( + client, + scheduledExecutor, + chatId, + from, + rule.getWarningTemplate(), + rule.getWarnTtlSeconds(), + messageUtils + ); + } + + if (userId != null) { + if (action == ViolationAction.DELETE_AND_MUTE) { + BotModerationHelper.muteUser(client, chatId, userId, ModerationConstants.DEFAULT_MUTE_DURATION_SECONDS); + } else if (action == ViolationAction.DELETE_AND_KICK) { + BotModerationHelper.kickUser(client, chatId, userId); + } + } + } + + private BotModerationRule findMatchingRule(List rules, String chatId, Integer threadId) { + for (BotModerationRule r : rules) { + if (chatId.equals(r.getChatId())) { + if (r.getThreadId() != null) { + if (r.getThreadId().equals(threadId)) { + return r; + } + } else { + return r; + } + } + } + for (BotModerationRule r : rules) { + if (ModerationConstants.GLOBAL_CHAT_ID.equals(r.getChatId())) { + return r; + } + } + return null; + } + + private BotModerationRule createDefaultRule(Long botId) { + Bot bot = botRepository.findById(botId).orElse(null); + return BotModerationRule.builder() + .bot(bot) + .chatId(ModerationConstants.GLOBAL_CHAT_ID) + .enabled(false) + .antiForwardEnabled(false) + .antiLinkEnabled(false) + .defaultProfanityFilter(true) + .mediaMode(MediaMode.ALL) + .actionOnViolation(ViolationAction.DELETE_AND_WARN) + .warnTtlSeconds(ModerationConstants.DEFAULT_WARN_TTL_SECONDS) + .warningTemplate(ModerationConstants.DEFAULT_WARNING_TEMPLATE) + .captchaEnabled(false) + .captchaMode(CaptchaMode.BUTTON) + .captchaTimeoutSeconds(ModerationConstants.DEFAULT_CAPTCHA_TIMEOUT_SECONDS) + .build(); + } + + private BotModerationRuleDto toDto(BotModerationRule rule) { + return BotModerationRuleDto.builder() + .id(rule.getId()) + .botId(rule.getBot() != null ? rule.getBot().getId() : null) + .chatId(rule.getChatId()) + .threadId(rule.getThreadId()) + .enabled(rule.isEnabled()) + .antiForwardEnabled(rule.isAntiForwardEnabled()) + .antiLinkEnabled(rule.isAntiLinkEnabled()) + .allowedLinks(rule.getAllowedLinks()) + .stopWords(rule.getStopWords()) + .defaultProfanityFilter(rule.isDefaultProfanityFilter()) + .mediaMode(rule.getMediaMode()) + .actionOnViolation(rule.getActionOnViolation()) + .warningTemplate(rule.getWarningTemplate()) + .warnTtlSeconds(rule.getWarnTtlSeconds()) + .captchaEnabled(rule.isCaptchaEnabled()) + .captchaMode(rule.getCaptchaMode()) + .captchaTimeoutSeconds(rule.getCaptchaTimeoutSeconds()) + .captchaMessageTemplate(rule.getCaptchaMessageTemplate()) + .build(); + } +} diff --git a/backend/src/main/java/com/launchly/bot/service/impl/BotServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/BotServiceImpl.java index 67984bdf..aee029d7 100644 --- a/backend/src/main/java/com/launchly/bot/service/impl/BotServiceImpl.java +++ b/backend/src/main/java/com/launchly/bot/service/impl/BotServiceImpl.java @@ -1,6 +1,6 @@ package com.launchly.bot.service.impl; -import com.launchly.common.security.CustomUserDetails; +import com.launchly.common.constant.CacheConstants; import org.springframework.web.client.RestTemplate; import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; @@ -15,47 +15,45 @@ import com.launchly.bot.dto.response.BotStatsResponse; import com.launchly.bot.dto.response.BotUserResponse; import com.launchly.bot.dto.response.FlowSchemaResponse; +import com.launchly.bot.constant.BotConstants; +import com.launchly.bot.constant.TelegramConstants; import com.launchly.bot.entity.Bot; import com.launchly.bot.entity.BotMember; import com.launchly.bot.entity.FlowSchema; -import com.launchly.bot.mapper.BotMapper; +import com.launchly.bot.mapper.BotResponseFactory; import com.launchly.bot.repository.BotRepository; import com.launchly.bot.repository.BotMemberRepository; import com.launchly.bot.repository.BotUserRepository; import com.launchly.bot.repository.FlowSchemaRepository; import com.launchly.bot.repository.InstalledTemplateRepository; import com.launchly.bot.repository.AccountTemplateRepository; +import com.launchly.bot.service.BotLifecycleService; import com.launchly.bot.service.BotService; -import com.launchly.bot.telegram.TelegramBotManager; +import com.launchly.bot.service.BotSubscriberService; import com.launchly.billing.service.PlanLimitService; import com.launchly.common.exception.AppException; import com.launchly.common.utils.EncryptionUtil; import com.launchly.media.service.MediaService; import com.launchly.bot.dto.request.BotUserCreateRequest; import com.launchly.bot.dto.request.BotUserUpdateRequest; -import com.launchly.bot.entity.BotUser; -import com.launchly.broadcast.repository.BotUserTagRepository; -import com.launchly.broadcast.repository.TagRepository; -import com.launchly.broadcast.entity.BotUserTag; -import com.launchly.broadcast.entity.Tag; import com.launchly.bot.validator.BotAccessValidator; import com.launchly.bot.validator.FlowSchemaValidator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.Duration; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.HashMap; -import java.util.stream.Collectors; +import java.util.*; + import com.launchly.admin.service.UserAuditService; @Slf4j @@ -67,25 +65,50 @@ public class BotServiceImpl implements BotService { private final FlowSchemaRepository flowSchemaRepository; private final BotUserRepository botUserRepository; private final UserQueryService userQueryService; - private final BotMapper botMapper; private final EncryptionUtil encryptionUtil; - private final TelegramBotManager telegramBotManager; private final ObjectMapper objectMapper; private final PlanLimitService planLimitService; private final MediaService mediaService; private final StringRedisTemplate redisTemplate; - private final BotUserTagRepository botUserTagRepository; - private final TagRepository tagRepository; private final BotMemberRepository botMemberRepository; private final InstalledTemplateRepository installedTemplateRepository; private final AccountTemplateRepository accountTemplateRepository; private final UserAuditService userAuditService; private final FlowSchemaValidator flowSchemaValidator; private final BotAccessValidator botAccessValidator; + private final BotLifecycleService botLifecycleService; + private final BotSubscriberService botSubscriberService; + private final BotResponseFactory botResponseFactory; + private final RestTemplate restTemplate; + private final TransactionTemplate transactionTemplate; + private final CacheManager cacheManager; + + private record TelegramBotInfo(String username, String firstName) {} + + private TelegramBotInfo fetchTelegramBotInfo(String unencryptedToken) { + if (unencryptedToken == null || unencryptedToken.isBlank() || BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(unencryptedToken)) { + return new TelegramBotInfo(null, null); + } + try { + String url = TelegramConstants.BOT_API_URL + unencryptedToken + "/getMe"; + ResponseEntity responseEntity = restTemplate.getForEntity(url, String.class); + if (responseEntity.getStatusCode().is2xxSuccessful() && responseEntity.getBody() != null) { + JsonNode responseNode = objectMapper.readTree(responseEntity.getBody()); + if (responseNode.has("ok") && responseNode.get("ok").asBoolean()) { + JsonNode result = responseNode.get("result"); + String username = result.has("username") ? result.get("username").asText() : null; + String firstName = result.has("first_name") ? result.get("first_name").asText() : null; + return new TelegramBotInfo(username, firstName); + } + } + } catch (Exception e) { + log.warn("Could not fetch Telegram bot info: {}", e.getMessage(), e); + } + return new TelegramBotInfo(null, null); + } @Override - @Transactional - @CacheEvict(value = "bots", key = "#userId") + @CacheEvict(value = CacheConstants.BOTS, key = "#userId") public BotResponse createBot(BotCreateRequest request, Long userId) { User user = userQueryService.getUserOrThrow(userId); @@ -95,66 +118,91 @@ public BotResponse createBot(BotCreateRequest request, Long userId) { rawToken = encryptionUtil.decrypt(sourceBot.getTelegramToken()); } - boolean isDummy = (rawToken == null || rawToken.trim().isEmpty() || "0000000000:dummyTokenPlaceholderForNoBotConfig".equals(rawToken)); + boolean isDummy = (rawToken == null || rawToken.trim().isEmpty() || BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(rawToken)); if (!isDummy) { planLimitService.checkBotLimit(userId, rawToken); } if (rawToken == null || rawToken.trim().isEmpty()) { - rawToken = "0000000000:dummyTokenPlaceholderForNoBotConfig"; + rawToken = BotConstants.DUMMY_TOKEN_PLACEHOLDER; } - String encryptedToken = encryptionUtil.encrypt(rawToken); + final String finalRawToken = rawToken; + final boolean finalIsDummy = isDummy; + final TelegramBotInfo tgInfo = !finalIsDummy ? fetchTelegramBotInfo(finalRawToken) : new TelegramBotInfo(null, null); + final String encryptedToken = encryptionUtil.encrypt(finalRawToken); + + Bot bot = transactionTemplate.execute(status -> { + List existingBots = botRepository.findAllByUserId(userId); + String inheritedCustomFields = existingBots.stream() + .map(Bot::getCustomFieldsData) + .filter(data -> data != null && !data.trim().isEmpty() && !data.trim().equals("{}")) + .findFirst() + .orElse(null); - Bot bot = Bot.builder() - .name(request.name()) - .description(request.description()) - .telegramToken(encryptedToken) - .user(user) - .build(); + String botName = request.name(); + if ((botName == null || botName.isBlank()) && tgInfo.firstName() != null) { + botName = tgInfo.firstName(); + } - bot = botRepository.save(bot); + Bot newBot = Bot.builder() + .name(botName) + .username(tgInfo.username()) + .description(request.description()) + .telegramToken(encryptedToken) + .customFieldsData(inheritedCustomFields) + .user(user) + .build(); - if (!"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(rawToken)) { - releaseTokenFromOtherBots(rawToken, userId, bot.getId()); - updateBotTelegramInfo(bot, rawToken); - bot = botRepository.save(bot); - } + newBot = botRepository.save(newBot); + + if (!finalIsDummy) { + botLifecycleService.releaseTokenFromOtherBots(finalRawToken, userId, newBot.getId()); + } + + FlowSchema schema = FlowSchema.builder() + .bot(newBot) + .build(); + flowSchemaRepository.save(schema); - FlowSchema schema = FlowSchema.builder() - .bot(bot) - .build(); - flowSchemaRepository.save(schema); + List ownerMembers = botMemberRepository.findByBotOwnerId(userId); + Map uniqueMembers = new HashMap<>(); + for (BotMember m : ownerMembers) { + uniqueMembers.putIfAbsent(m.getUser().getId(), m); + } + for (BotMember m : uniqueMembers.values()) { + BotMember member = BotMember.builder() + .bot(newBot) + .user(m.getUser()) + .role(m.getRole()) + .inboxSeat(m.isInboxSeat()) + .billingPermission(m.isBillingPermission()) + .build(); + botMemberRepository.save(member); + if (cacheManager != null) { + Cache cache = cacheManager.getCache(CacheConstants.BOTS); + if (cache != null) { + cache.evict(m.getUser().getId()); + } + } + } + + userAuditService.logBotConnected(user, newBot.getId(), newBot.getName(), newBot.getCreatedAt()); - userAuditService.logBotConnected(user, bot.getId(), bot.getName(), bot.getCreatedAt()); + return newBot; + }); return toBotResponseWithStats(bot); } @Override @Transactional(readOnly = true) - @Cacheable(value = "bots", key = "#userId") + @Cacheable(value = CacheConstants.BOTS, key = "#userId") public List getBotsByUser(Long userId) { - List ownedBots = botRepository.findAllByUserId(userId); + List allBots = new ArrayList<>(botRepository.findAllAccessibleByUserId(userId)); + allBots.sort(Comparator.comparing(Bot::getId)); List memberships = botMemberRepository.findByUserId(userId); - List memberBots = new ArrayList<>(); - - for (BotMember bm : memberships) { - if (bm.getBot() != null) { - memberBots.add(bm.getBot()); - } - } - - List allBots = new ArrayList<>(ownedBots); - for (Bot b : memberBots) { - if (allBots.stream().noneMatch(existing -> existing.getId().equals(b.getId()))) { - allBots.add(b); - } - } - - return allBots.stream() - .map(this::toBotResponseWithStats) - .collect(Collectors.toList()); + return botResponseFactory.toBotResponseListWithStats(allBots, userId, memberships); } @Override @@ -178,81 +226,111 @@ public BotDetailResponse getBotById(Long id, Long userId) { schemaResponse, bot.getCreatedAt(), bot.isTemplate(), - bot.getTemplateName() + bot.getTemplateName(), + bot.getResponseMode() ); } @Override - @Transactional - @CacheEvict(value = "bots", key = "#userId") + @CacheEvict(value = CacheConstants.BOTS, key = "#userId") public BotResponse updateBot(Long id, BotUpdateRequest request, Long userId) { - Bot bot = findBotByIdAndUser(id, userId); - botAccessValidator.validateWriteAccess(bot, userId); + Bot existingBot = findBotByIdAndUser(id, userId); + botAccessValidator.validateWriteAccess(existingBot, userId); - if (request.name() != null) { - bot.setName(request.name()); - } String rawToken = request.telegramToken(); if (request.copyTokenFromBotId() != null) { Bot sourceBot = findBotByIdAndUser(request.copyTokenFromBotId(), userId); rawToken = encryptionUtil.decrypt(sourceBot.getTelegramToken()); } - if (rawToken != null) { - String decryptedToken = encryptionUtil.decrypt(bot.getTelegramToken()); - boolean wasDummy = "0000000000:dummyTokenPlaceholderForNoBotConfig".equals(decryptedToken); - boolean isNewReal = !"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(rawToken); + TelegramBotInfo tgInfo = null; + if (rawToken != null && !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(rawToken)) { + tgInfo = fetchTelegramBotInfo(rawToken); + } - if (wasDummy && isNewReal) { - planLimitService.checkBotLimit(userId, rawToken); - } + final TelegramBotInfo finalTgInfo = tgInfo; + final String finalRawToken = rawToken; + + String oldPublicIdToDelete = null; + if (request.avatar() != null && !request.avatar().equals(existingBot.getAvatar())) { + oldPublicIdToDelete = existingBot.getAvatarPublicId(); + } - if (isNewReal) { - releaseTokenFromOtherBots(rawToken, userId, bot.getId()); - bot.setTemplate(false); + Bot updatedBot = transactionTemplate.execute(status -> { + Bot bot = findBotByIdAndUser(id, userId); + if (request.name() != null) { + bot.setName(request.name()); } - bot.setTelegramToken(encryptionUtil.encrypt(rawToken)); - if (!"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(rawToken)) { - updateBotTelegramInfo(bot, rawToken); - } else { - bot.setUsername(null); + if (request.responseMode() != null) { + bot.setResponseMode(request.responseMode()); } - } - if (request.description() != null) { - bot.setDescription(request.description()); - } - if (request.avatar() != null) { - if (!request.avatar().equals(bot.getAvatar())) { - String oldPublicId = bot.getAvatarPublicId(); - if (oldPublicId != null && !oldPublicId.trim().isEmpty()) { - try { - mediaService.delete(oldPublicId, userId); - } catch (Exception e) { - log.error("Failed to delete old avatar publicId {} from Cloudinary: {}", oldPublicId, e.getMessage()); + + if (finalRawToken != null) { + String decryptedToken = encryptionUtil.decrypt(bot.getTelegramToken()); + boolean wasDummy = BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(decryptedToken); + boolean isNewReal = !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(finalRawToken); + + if (wasDummy && isNewReal) { + planLimitService.checkBotLimit(userId, finalRawToken); + } + + if (isNewReal) { + botLifecycleService.releaseTokenFromOtherBots(finalRawToken, userId, bot.getId()); + bot.setTemplate(false); + } + + bot.setTelegramToken(encryptionUtil.encrypt(finalRawToken)); + if (isNewReal) { + if (finalTgInfo != null) { + if (finalTgInfo.username() != null) { + bot.setUsername(finalTgInfo.username()); + } + if (finalTgInfo.firstName() != null && (bot.getName() == null || bot.getName().isBlank())) { + bot.setName(finalTgInfo.firstName()); + } } + } else { + bot.setUsername(null); } + } + + if (request.description() != null) { + bot.setDescription(request.description()); + } + + if (request.avatar() != null) { bot.setAvatar(request.avatar()); bot.setAvatarPublicId(request.avatarPublicId()); } + + bot.setUpdatedAt(LocalDateTime.now()); + return botRepository.save(bot); + }); + + if (oldPublicIdToDelete != null && !oldPublicIdToDelete.trim().isEmpty()) { + try { + mediaService.delete(oldPublicIdToDelete, userId); + } catch (Exception e) { + log.warn("Failed to delete old avatar publicId {} from Cloudinary: {}", oldPublicIdToDelete, e.getMessage(), e); + } } - bot = botRepository.save(bot); - return toBotResponseWithStats(bot); + return toBotResponseWithStats(updatedBot); } @Override @Transactional @Caching(evict = { - @CacheEvict(value = "bots", key = "#userId"), - @CacheEvict(value = "flow_schemas", key = "#id") + @CacheEvict(value = CacheConstants.BOTS, key = "#userId"), + @CacheEvict(value = CacheConstants.FLOW_SCHEMAS, key = "#id") }) public void deleteBot(Long id, Long userId) { Bot bot = findBotByIdAndUser(id, userId); botAccessValidator.validateWriteAccess(bot, userId); if (bot.isActive()) { - telegramBotManager.unregisterBot(bot.getId()); + botLifecycleService.unregisterBot(bot.getId()); } installedTemplateRepository.deleteAllByBotId(bot.getId()); @@ -261,123 +339,23 @@ public void deleteBot(Long id, Long userId) { } @Override - @CacheEvict(value = "bots", key = "#userId") public BotResponse startBot(Long id, Long userId) { - Bot bot = findBotByIdAndUser(id, userId); - botAccessValidator.validateWriteAccess(bot, userId); - - if (bot.isActive()) { - throw new AppException(HttpStatus.CONFLICT, "bot.error.already_running"); - } - - String decryptedToken = encryptionUtil.decrypt(bot.getTelegramToken()); - if ("0000000000:dummyTokenPlaceholderForNoBotConfig".equals(decryptedToken)) { - throw new AppException(HttpStatus.BAD_REQUEST, "bot.error.token_required_to_start"); - } - - - List activeBotsList = botRepository.findAllByActiveTrue(); - for (Bot activeBot : activeBotsList) { - if (!activeBot.getId().equals(bot.getId())) { - String activeToken = encryptionUtil.decrypt(activeBot.getTelegramToken()); - if (decryptedToken.equals(activeToken)) { - telegramBotManager.unregisterBot(activeBot.getId()); - activeBot.setActive(false); - botRepository.save(activeBot); - log.info("Automatically deactivated bot id={} ('{}') because its token was assigned to bot id={} ('{}')", - activeBot.getId(), activeBot.getName(), bot.getId(), bot.getName()); - } - } - } - - telegramBotManager.registerBot(bot); - - try { - bot.setActive(true); - bot.setRunsCount(bot.getRunsCount() + 1); - bot = botRepository.save(bot); - } catch (Exception e) { - try { - telegramBotManager.unregisterBot(bot.getId()); - } catch (Exception ex) { - } - throw e; - } - - return toBotResponseWithStats(bot); + return botLifecycleService.startBot(id, userId); } @Override - @Transactional - @Caching(evict = { - @CacheEvict(value = "bots", key = "#userId"), - @CacheEvict(value = "flow_schemas", key = "#id") - }) public BotResponse publishBot(Long id, Long userId) { - Bot bot = findBotByIdAndUser(id, userId); - botAccessValidator.validateWriteAccess(bot, userId); - - bot.setRunsCount(bot.getRunsCount() + 1); - bot.setUpdatedAt(LocalDateTime.now()); - - if (!bot.isActive()) { - boolean hasRealToken = false; - try { - if (bot.getTelegramToken() != null && !bot.getTelegramToken().isBlank()) { - String decrypted = encryptionUtil.decrypt(bot.getTelegramToken()); - if (decrypted != null && !decrypted.isBlank() && - !"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(decrypted)) { - hasRealToken = true; - } - } - } catch (Exception e) { - } - - if (hasRealToken) { - try { - telegramBotManager.registerBot(bot); - bot.setActive(true); - } catch (Exception e) { - log.error("Failed to register bot on publish: {}", e.getMessage(), e); - } - } - } - - bot = botRepository.save(bot); - return toBotResponseWithStats(bot); + return botLifecycleService.publishBot(id, userId); } @Override - @CacheEvict(value = "bots", key = "#userId") public BotResponse stopBot(Long id, Long userId) { - Bot bot = findBotByIdAndUser(id, userId); - botAccessValidator.validateWriteAccess(bot, userId); - - if (!bot.isActive()) { - throw new AppException(HttpStatus.CONFLICT, "bot.error.not_running"); - } - - telegramBotManager.unregisterBot(bot.getId()); - - try { - bot.setActive(false); - bot = botRepository.save(bot); - } catch (Exception e) { - try { - telegramBotManager.registerBot(bot); - } catch (Exception ex) { - log.error("Failed to re-register bot {} after stop failure: {}", bot.getId(), ex.getMessage(), ex); - } - throw e; - } - - - return toBotResponseWithStats(bot); + return botLifecycleService.stopBot(id, userId); } @Override @Transactional - @Cacheable(value = "flow_schemas", key = "#botId") + @Cacheable(value = CacheConstants.FLOW_SCHEMAS, key = "#botId") public FlowSchemaResponse getFlowSchema(Long botId, Long userId) { Bot bot = findBotByIdAndUser(botId, userId); FlowSchema schema = flowSchemaRepository.findByBotId(bot.getId()) @@ -387,7 +365,10 @@ public FlowSchemaResponse getFlowSchema(Long botId, Long userId) { @Override @Transactional - @CacheEvict(value = "flow_schemas", key = "#botId") + @Caching(evict = { + @CacheEvict(value = CacheConstants.BOTS, key = "#userId"), + @CacheEvict(value = CacheConstants.FLOW_SCHEMAS, key = "#botId") + }) public FlowSchemaResponse saveFlowSchema(Long botId, FlowSchemaRequest request, Long userId) { Bot bot = findBotByIdAndUser(botId, userId); botAccessValidator.validateWriteAccess(bot, userId); @@ -402,34 +383,14 @@ public FlowSchemaResponse saveFlowSchema(Long botId, FlowSchemaRequest request, schema.setNodes(toJsonString(nodesNode)); schema.setEdges(toJsonString(edgesNode)); + if (schema.getPublishedNodes() == null || schema.getPublishedNodes().isBlank() || "[]".equals(schema.getPublishedNodes().trim())) { + schema.setPublishedNodes(toJsonString(nodesNode)); + schema.setPublishedEdges(toJsonString(edgesNode)); + } + schema = flowSchemaRepository.save(schema); - redisTemplate.delete("launchly:bot:schema:" + botId); userAuditService.logAutomationModified(bot.getUser(), bot.getId(), bot.getName(), LocalDateTime.now()); - if (!bot.isActive()) { - boolean hasRealToken = false; - try { - if (bot.getTelegramToken() != null && !bot.getTelegramToken().isBlank()) { - String decrypted = encryptionUtil.decrypt(bot.getTelegramToken()); - if (decrypted != null && !decrypted.isBlank() && - !"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(decrypted)) { - hasRealToken = true; - } - } - } catch (Exception e) { - } - - if (hasRealToken) { - try { - telegramBotManager.registerBot(bot); - bot.setActive(true); - bot.setUpdatedAt(LocalDateTime.now()); - botRepository.save(bot); - } catch (Exception e) { - log.error("Failed to register bot: {}", e.getMessage(), e); - } - } - } return toFlowSchemaResponse(schema); } @@ -437,186 +398,25 @@ public FlowSchemaResponse saveFlowSchema(Long botId, FlowSchemaRequest request, @Override @Transactional(readOnly = true) public List getBotUsers(Long botId, Long userId) { - Bot bot = findBotByIdAndUser(botId, userId); - return botUserRepository.findAllByBotId(bot.getId()).stream() - .map(bu -> { - List tags = botUserTagRepository.findByBotUserId(bu.getId()).stream() - .map(but -> but.getTag().getName()) - .toList(); - return new BotUserResponse( - bu.getId(), - bu.getTelegramId(), - bu.getUsername(), - bu.getFirstName(), - bu.getLastName(), - bu.getCurrentNodeId(), - bu.getPhotoUrl(), - bu.getMetadata(), - tags, - bu.getCreatedAt() - ); - }) - .toList(); + return botSubscriberService.getBotUsers(botId, userId); } @Override - @Transactional public BotUserResponse updateBotUser(Long botId, Long botUserId, BotUserUpdateRequest request, Long userId) { - Bot bot = findBotByIdAndUser(botId, userId); - botAccessValidator.validateWriteAccess(bot, userId); - BotUser botUser = botUserRepository.findById(botUserId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.contact_not_found")); - - if (!botUser.getBot().getId().equals(bot.getId())) { - throw new AppException(HttpStatus.FORBIDDEN, "bot.error.contact_access_denied"); - } - - if (request.firstName() != null) { - botUser.setFirstName(request.firstName()); - } - if (request.lastName() != null) { - botUser.setLastName(request.lastName()); - } - if (request.metadata() != null) { - botUser.setMetadata(request.metadata()); - } - - botUser = botUserRepository.save(botUser); - - if (request.tags() != null) { - botUserTagRepository.deleteByBotUserId(botUser.getId()); - botUserTagRepository.flush(); - for (String tagName : request.tags()) { - if (tagName == null || tagName.trim().isEmpty()) continue; - String trimmedName = tagName.trim(); - Tag tag = tagRepository.findByBotIdAndName(bot.getId(), trimmedName) - .orElseGet(() -> tagRepository.save( - Tag.builder() - .name(trimmedName) - .bot(bot) - .build() - )); - BotUserTag botUserTag = BotUserTag.builder() - .botUser(botUser) - .tag(tag) - .build(); - botUserTagRepository.save(botUserTag); - } - } - - List tags = botUserTagRepository.findByBotUserId(botUser.getId()).stream() - .map(but -> but.getTag().getName()) - .toList(); - return new BotUserResponse( - botUser.getId(), - botUser.getTelegramId(), - botUser.getUsername(), - botUser.getFirstName(), - botUser.getLastName(), - botUser.getCurrentNodeId(), - botUser.getPhotoUrl(), - botUser.getMetadata(), - tags, - botUser.getCreatedAt() - ); + return botSubscriberService.updateBotUser(botId, botUserId, request, userId); } @Override @Transactional public BotUserResponse createBotUser(Long botId, BotUserCreateRequest request, Long userId) { - Bot bot = findBotByIdAndUser(botId, userId); - botAccessValidator.validateWriteAccess(bot, userId); - planLimitService.checkBotUserLimit(bot.getId()); - - Long minTelegramId = botUserRepository.findMinTelegramIdByBotId(bot.getId()).orElse(0L); - Long nextTelegramId = minTelegramId <= 0 ? minTelegramId - 1 : -1L; - - String metadataJson = "{}"; - try { - Map metaMap = new HashMap<>(); - metaMap.put("paused", false); - metaMap.put("unsubscribed", false); - metaMap.put("phone", request.phone()); - metaMap.put("email", request.email()); - metaMap.put("gender", request.gender()); - - Map customFields = new HashMap<>(); - if (request.phone() != null && !request.phone().trim().isEmpty()) { - customFields.put("Phone", request.phone().trim()); - } - if (request.email() != null && !request.email().trim().isEmpty()) { - customFields.put("Email", request.email().trim()); - } - if (request.gender() != null && !request.gender().trim().isEmpty()) { - customFields.put("Gender", request.gender().trim()); - } - metaMap.put("customFields", customFields); - - metadataJson = objectMapper.writeValueAsString(metaMap); - } catch (Exception e) { - log.error("Failed to serialize metadata for contact creation", e); - } - - BotUser botUser = BotUser.builder() - .telegramId(nextTelegramId) - .firstName(request.firstName()) - .lastName(request.lastName()) - .metadata(metadataJson) - .bot(bot) - .build(); - - botUser = botUserRepository.save(botUser); - - if (request.tags() != null) { - for (String tagName : request.tags()) { - if (tagName == null || tagName.trim().isEmpty()) continue; - String trimmedName = tagName.trim(); - Tag tag = tagRepository.findByBotIdAndName(bot.getId(), trimmedName) - .orElseGet(() -> tagRepository.save( - Tag.builder() - .name(trimmedName) - .bot(bot) - .build() - )); - BotUserTag botUserTag = BotUserTag.builder() - .botUser(botUser) - .tag(tag) - .build(); - botUserTagRepository.save(botUserTag); - } - } - - List tags = botUserTagRepository.findByBotUserId(botUser.getId()).stream() - .map(but -> but.getTag().getName()) - .toList(); - - return new BotUserResponse( - botUser.getId(), - botUser.getTelegramId(), - botUser.getUsername(), - botUser.getFirstName(), - botUser.getLastName(), - botUser.getCurrentNodeId(), - botUser.getPhotoUrl(), - botUser.getMetadata(), - tags, - botUser.getCreatedAt() - ); + return botSubscriberService.createBotUser(botId, request, userId); } @Override @Transactional public void deleteBotUser(Long botId, Long botUserId, Long userId) { - Bot bot = findBotByIdAndUser(botId, userId); - botAccessValidator.validateWriteAccess(bot, userId); - BotUser botUser = botUserRepository.findById(botUserId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.contact_not_found")); - - if (!botUser.getBot().getId().equals(bot.getId())) { - throw new AppException(HttpStatus.FORBIDDEN, "bot.error.contact_access_denied"); - } - botUserRepository.delete(botUser); + botSubscriberService.deleteBotUser(botId, botUserId, userId); } @Override @@ -645,7 +445,9 @@ private FlowSchemaResponse toFlowSchemaResponse(FlowSchema schema) { schema.getId(), schema.getVersion(), parseJson(schema.getNodes()), - parseJson(schema.getEdges()) + parseJson(schema.getEdges()), + parseJson(schema.getEffectivePublishedNodes()), + parseJson(schema.getEffectivePublishedEdges()) ); } @@ -665,107 +467,8 @@ private String toJsonString(JsonNode jsonNode) { } } - private void updateBotTelegramInfo(Bot bot, String unencryptedToken) { - try { - String url = "https://api.telegram.org/bot" + unencryptedToken + "/getMe"; - org.springframework.http.client.SimpleClientHttpRequestFactory factory = new org.springframework.http.client.SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(Duration.ofMillis(1500)); - factory.setReadTimeout(Duration.ofMillis(1500)); - RestTemplate restTemplate = new RestTemplate(factory); - org.springframework.http.ResponseEntity responseEntity = restTemplate.getForEntity(url, String.class); - if (responseEntity.getStatusCode().is2xxSuccessful() && responseEntity.getBody() != null) { - JsonNode responseNode = objectMapper.readTree(responseEntity.getBody()); - if (responseNode.has("ok") && responseNode.get("ok").asBoolean()) { - JsonNode result = responseNode.get("result"); - if (result.has("username")) { - bot.setUsername(result.get("username").asText()); - } - if (result.has("first_name")) { - bot.setName(result.get("first_name").asText()); - } - } - } - } catch (Exception e) { - log.debug("Could not fetch Telegram bot info: {}", e.getMessage()); - } - } - - private void releaseTokenFromOtherBots(String token, Long userId, Long currentBotId) { - if (token == null || "0000000000:dummyTokenPlaceholderForNoBotConfig".equals(token)) { - return; - } - - List userBots = botRepository.findAllByUserId(userId); - for (Bot otherBot : userBots) { - if (!otherBot.getId().equals(currentBotId)) { - try { - String decrypted = encryptionUtil.decrypt(otherBot.getTelegramToken()); - if (token.equals(decrypted)) { - if (otherBot.isActive()) { - telegramBotManager.unregisterBot(otherBot.getId()); - otherBot.setActive(false); - } - otherBot.setTelegramToken(encryptionUtil.encrypt("0000000000:dummyTokenPlaceholderForNoBotConfig")); - otherBot.setUsername(null); - botRepository.save(otherBot); - log.info("Reassigned token to bot id={}. Automatically reset bot id={} ('{}') to Without bot (inactive)", - currentBotId, otherBot.getId(), otherBot.getName()); - } - } catch (Exception e) { - log.error("Failed to release token from other bot id={}: {}", otherBot.getId(), e.getMessage()); - } - } - } - } - private BotResponse toBotResponseWithStats(Bot bot) { - if (bot == null) return null; - BotResponse response = botMapper.toBotResponse(bot); - - boolean hasToken = false; - try { - String decryptedToken = encryptionUtil.decrypt(bot.getTelegramToken()); - hasToken = decryptedToken != null && !decryptedToken.isBlank() && !"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(decryptedToken); - } catch (Exception e) { - log.error("Failed to decrypt token for bot id={}", bot.getId(), e); - } - - long totalUsers = hasToken ? botUserRepository.countByBotId(bot.getId()) : 0; - - String role = "Owner"; - try { - org.springframework.security.core.Authentication auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.getPrincipal() instanceof CustomUserDetails userDetails) { - Long currentUserId = userDetails.getId(); - if (!bot.getUser().getId().equals(currentUserId)) { - role = botAccessValidator.getWorkspaceMembership(bot, currentUserId) - .map(BotMember::getRole) - .orElse("Viewer"); - } - } - } catch (Exception e) { - log.error("Failed to determine member role in toBotResponseWithStats", e); - } - - return new BotResponse( - response.id(), - response.name(), - response.username(), - response.description(), - response.avatar(), - response.avatarPublicId(), - bot.isBlocked() ? false : response.active(), - bot.isBlocked(), - bot.getBlockReason(), - response.createdAt(), - response.updatedAt(), - totalUsers, - hasToken, - role, - bot.isTemplate(), - bot.getTemplateName(), - bot.getRunsCount() - ); + return botResponseFactory.toBotResponseWithStats(bot); } @Override @@ -780,6 +483,16 @@ public String getCustomFields(Long botId, Long userId) { public String saveCustomFields(Long botId, String customFieldsJson, Long userId) { Bot bot = findBotByIdAndUser(botId, userId); botAccessValidator.validateWriteAccess(bot, userId); + if (customFieldsJson != null && !customFieldsJson.trim().isEmpty()) { + if (customFieldsJson.length() > 100_000) { + throw new AppException(HttpStatus.BAD_REQUEST, "bot.error.custom_fields_payload_too_large"); + } + try { + objectMapper.readTree(customFieldsJson); + } catch (Exception e) { + throw new AppException(HttpStatus.BAD_REQUEST, "bot.error.invalid_json"); + } + } bot.setCustomFieldsData(customFieldsJson); botRepository.save(bot); return bot.getCustomFieldsData(); @@ -800,4 +513,18 @@ public String saveAutomationFolders(String foldersJson, Long userId) { userQueryService.save(user); return user.getAutomationFolders(); } + + @Override + @Transactional + public void deleteAllUserData(Long userId) { + List ownedBots = botRepository.findAllByUserId(userId); + for (Bot b : ownedBots) { + botRepository.delete(b); + } + + List memberships = botMemberRepository.findByUserId(userId); + for (BotMember bm : memberships) { + botMemberRepository.delete(bm); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/com/launchly/bot/service/impl/BotSubscriberServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/BotSubscriberServiceImpl.java new file mode 100644 index 00000000..67599981 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/impl/BotSubscriberServiceImpl.java @@ -0,0 +1,251 @@ +package com.launchly.bot.service.impl; + +import tools.jackson.databind.ObjectMapper; +import com.launchly.billing.service.PlanLimitService; +import com.launchly.bot.dto.request.BotUserCreateRequest; +import com.launchly.bot.dto.request.BotUserUpdateRequest; +import com.launchly.bot.dto.response.BotUserResponse; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.BotSubscriberService; +import com.launchly.bot.validator.BotAccessValidator; +import com.launchly.broadcast.entity.BotUserTag; +import com.launchly.broadcast.entity.Tag; +import com.launchly.broadcast.repository.BotUserTagRepository; +import com.launchly.broadcast.repository.TagRepository; +import com.launchly.common.exception.AppException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.launchly.bot.constant.BotConstants; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BotSubscriberServiceImpl implements BotSubscriberService { + + private final BotUserRepository botUserRepository; + private final BotUserTagRepository botUserTagRepository; + private final TagRepository tagRepository; + private final BotAccessValidator botAccessValidator; + private final PlanLimitService planLimitService; + private final ObjectMapper objectMapper; + + @Override + @Transactional(readOnly = true) + public List getBotUsers(Long botId, Long userId) { + Bot bot = botAccessValidator.getBotWithAccess(botId, userId); + List botUsers = botUserRepository.findAllByBotId(bot.getId()); + if (botUsers.isEmpty()) { + return List.of(); + } + + List botUserIds = botUsers.stream().map(BotUser::getId).toList(); + List userTags = botUserTagRepository.findByBotUserIdIn(botUserIds); + Map> tagsByBotUserId = userTags.stream() + .filter(but -> but.getBotUser() != null && but.getTag() != null) + .collect(Collectors.groupingBy( + but -> but.getBotUser().getId(), + Collectors.mapping(but -> but.getTag().getName(), Collectors.toList()) + )); + + return botUsers.stream() + .map(bu -> new BotUserResponse( + bu.getId(), + bu.getTelegramId(), + bu.getUsername(), + bu.getFirstName(), + bu.getLastName(), + bu.getCurrentNodeId(), + bu.getPhotoUrl(), + bu.getMetadata(), + tagsByBotUserId.getOrDefault(bu.getId(), List.of()), + bu.getCreatedAt(), + bu.getBot() != null ? bu.getBot().getId() : bot.getId(), + bu.getBot() != null ? bu.getBot().getName() : bot.getName() + )) + .toList(); + } + + @Override + @Transactional + public BotUserResponse updateBotUser(Long botId, Long botUserId, BotUserUpdateRequest request, Long userId) { + Bot bot = botAccessValidator.getBotWithAccess(botId, userId); + botAccessValidator.validateWriteAccess(bot, userId); + BotUser botUser = botUserRepository.findById(botUserId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.contact_not_found")); + + if (!botUser.getBot().getId().equals(bot.getId())) { + throw new AppException(HttpStatus.FORBIDDEN, "bot.error.contact_access_denied"); + } + + if (request.firstName() != null) { + botUser.setFirstName(request.firstName()); + } + if (request.lastName() != null) { + botUser.setLastName(request.lastName()); + } + if (request.metadata() != null) { + String metaStr = request.metadata().trim(); + if (!metaStr.isEmpty()) { + if (metaStr.length() > 50_000) { + throw new AppException(HttpStatus.BAD_REQUEST, "bot.error.metadata_too_large"); + } + try { + objectMapper.readTree(metaStr); + } catch (Exception e) { + throw new AppException(HttpStatus.BAD_REQUEST, "bot.error.invalid_json"); + } + } + botUser.setMetadata(metaStr); + } + + botUser = botUserRepository.save(botUser); + + if (request.tags() != null) { + botUserTagRepository.deleteByBotUserId(botUser.getId()); + botUserTagRepository.flush(); + for (String tagName : request.tags()) { + if (tagName == null || tagName.trim().isEmpty()) continue; + String trimmedName = tagName.trim(); + Tag tag = tagRepository.findByBotIdAndName(bot.getId(), trimmedName) + .orElseGet(() -> tagRepository.save( + Tag.builder() + .name(trimmedName) + .bot(bot) + .build() + )); + BotUserTag botUserTag = BotUserTag.builder() + .botUser(botUser) + .tag(tag) + .build(); + botUserTagRepository.save(botUserTag); + } + } + + List tags = botUserTagRepository.findByBotUserId(botUser.getId()).stream() + .map(but -> but.getTag().getName()) + .toList(); + return new BotUserResponse( + botUser.getId(), + botUser.getTelegramId(), + botUser.getUsername(), + botUser.getFirstName(), + botUser.getLastName(), + botUser.getCurrentNodeId(), + botUser.getPhotoUrl(), + botUser.getMetadata(), + tags, + botUser.getCreatedAt(), + bot.getId(), + bot.getName() + ); + } + + @Override + @Transactional + public BotUserResponse createBotUser(Long botId, BotUserCreateRequest request, Long userId) { + Bot bot = botAccessValidator.getBotWithAccess(botId, userId); + botAccessValidator.validateWriteAccess(bot, userId); + planLimitService.checkBotUserLimit(bot.getId()); + + Long minTelegramId = botUserRepository.findMinTelegramIdByBotId(bot.getId()).orElse(0L); + Long nextTelegramId = minTelegramId <= 0 ? minTelegramId - 1 : BotConstants.SYSTEM_BOT_ID; + + String metadataJson = "{}"; + try { + Map metaMap = new HashMap<>(); + metaMap.put("paused", false); + metaMap.put("unsubscribed", false); + metaMap.put("phone", request.phone()); + metaMap.put("email", request.email()); + metaMap.put("gender", request.gender()); + + Map customFields = new HashMap<>(); + if (request.phone() != null && !request.phone().trim().isEmpty()) { + customFields.put("Phone", request.phone().trim()); + } + if (request.email() != null && !request.email().trim().isEmpty()) { + customFields.put("Email", request.email().trim()); + } + if (request.gender() != null && !request.gender().trim().isEmpty()) { + customFields.put("Gender", request.gender().trim()); + } + metaMap.put("customFields", customFields); + + metadataJson = objectMapper.writeValueAsString(metaMap); + } catch (Exception e) { + log.error("Failed to serialize metadata for contact creation", e); + } + + BotUser botUser = BotUser.builder() + .telegramId(nextTelegramId) + .firstName(request.firstName()) + .lastName(request.lastName()) + .metadata(metadataJson) + .bot(bot) + .build(); + + botUser = botUserRepository.save(botUser); + + if (request.tags() != null) { + for (String tagName : request.tags()) { + if (tagName == null || tagName.trim().isEmpty()) continue; + String trimmedName = tagName.trim(); + Tag tag = tagRepository.findByBotIdAndName(bot.getId(), trimmedName) + .orElseGet(() -> tagRepository.save( + Tag.builder() + .name(trimmedName) + .bot(bot) + .build() + )); + BotUserTag botUserTag = BotUserTag.builder() + .botUser(botUser) + .tag(tag) + .build(); + botUserTagRepository.save(botUserTag); + } + } + + List tags = botUserTagRepository.findByBotUserId(botUser.getId()).stream() + .map(but -> but.getTag().getName()) + .toList(); + + return new BotUserResponse( + botUser.getId(), + botUser.getTelegramId(), + botUser.getUsername(), + botUser.getFirstName(), + botUser.getLastName(), + botUser.getCurrentNodeId(), + botUser.getPhotoUrl(), + botUser.getMetadata(), + tags, + botUser.getCreatedAt(), + bot.getId(), + bot.getName() + ); + } + + @Override + @Transactional + public void deleteBotUser(Long botId, Long botUserId, Long userId) { + Bot bot = botAccessValidator.getBotWithAccess(botId, userId); + botAccessValidator.validateWriteAccess(bot, userId); + BotUser botUser = botUserRepository.findById(botUserId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.contact_not_found")); + + if (!botUser.getBot().getId().equals(bot.getId())) { + throw new AppException(HttpStatus.FORBIDDEN, "bot.error.contact_access_denied"); + } + botUserRepository.delete(botUser); + } +} diff --git a/backend/src/main/java/com/launchly/bot/service/impl/BotUserProvisioningServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/BotUserProvisioningServiceImpl.java new file mode 100644 index 00000000..a156cc6b --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/impl/BotUserProvisioningServiceImpl.java @@ -0,0 +1,78 @@ +package com.launchly.bot.service.impl; + +import com.launchly.billing.service.PlanLimitService; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.BotUserProvisioningService; +import com.launchly.bot.service.UserAvatarService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BotUserProvisioningServiceImpl implements BotUserProvisioningService { + + private final BotUserRepository botUserRepository; + private final PlanLimitService planLimitService; + private final UserAvatarService userAvatarService; + + @Override + public BotUser getOrCreateBotUser(Bot bot, Update update, Long telegramUserId, TelegramClient telegramClient) { + return botUserRepository.findByTelegramIdAndBotId(telegramUserId, bot.getId()) + .map(existingUser -> { + if (existingUser.getPhotoUrl() == null && telegramClient != null) { + try { + userAvatarService.fetchAndSetPhotoUrl(existingUser, bot, telegramClient); + } catch (Exception e) { + log.debug("Could not fetch avatar for existing user {}: {}", existingUser.getTelegramId(), e.getMessage()); + } + } + return existingUser; + }) + .orElseGet(() -> { + planLimitService.checkBotUserLimit(bot.getId()); + String username = null; + String firstName = null; + String lastName = null; + + if (update.hasMessage() && update.getMessage().getFrom() != null) { + var from = update.getMessage().getFrom(); + username = from.getUserName(); + firstName = from.getFirstName(); + lastName = from.getLastName(); + } else if (update.hasCallbackQuery() && update.getCallbackQuery().getFrom() != null) { + var from = update.getCallbackQuery().getFrom(); + username = from.getUserName(); + firstName = from.getFirstName(); + lastName = from.getLastName(); + } else if (update.hasChatJoinRequest() && update.getChatJoinRequest().getUser() != null) { + var from = update.getChatJoinRequest().getUser(); + username = from.getUserName(); + firstName = from.getFirstName(); + lastName = from.getLastName(); + } + + BotUser newUser = BotUser.builder() + .telegramId(telegramUserId) + .username(username) + .firstName(firstName) + .lastName(lastName) + .bot(bot) + .build(); + BotUser savedUser = botUserRepository.save(newUser); + if (telegramClient != null) { + try { + userAvatarService.fetchAndSetPhotoUrl(savedUser, bot, telegramClient); + } catch (Exception e) { + log.debug("Could not fetch avatar for new user {}: {}", savedUser.getTelegramId(), e.getMessage()); + } + } + return savedUser; + }); + } +} diff --git a/backend/src/main/java/com/launchly/bot/service/impl/FlowEngineServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/FlowEngineServiceImpl.java index a215419c..92a4718f 100644 --- a/backend/src/main/java/com/launchly/bot/service/impl/FlowEngineServiceImpl.java +++ b/backend/src/main/java/com/launchly/bot/service/impl/FlowEngineServiceImpl.java @@ -1,114 +1,99 @@ package com.launchly.bot.service.impl; import com.launchly.analytics.entity.AnalyticsEventType; -import tools.jackson.core.type.TypeReference; -import tools.jackson.databind.ObjectMapper; +import com.launchly.analytics.service.AnalyticsService; +import com.launchly.bot.constant.BotConstants; +import com.launchly.bot.engine.cache.FlowSchemaCache; +import com.launchly.bot.engine.callstack.BotCallStackManager; +import com.launchly.bot.engine.callstack.CallStackFrame; import com.launchly.bot.engine.executor.NodeExecutor; +import com.launchly.bot.engine.model.DataCollectionState; import com.launchly.bot.engine.model.FlowEdge; import com.launchly.bot.engine.model.FlowNode; -import com.launchly.bot.engine.model.DataCollectionState; -import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import com.launchly.bot.engine.persister.BotMessagePersister; +import com.launchly.bot.engine.router.FlowNodeRouter; +import com.launchly.bot.engine.validator.BotInputValidator; import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotResponseMode; import com.launchly.bot.entity.BotUser; import com.launchly.bot.entity.FlowSchema; import com.launchly.bot.entity.NodeType; import com.launchly.bot.repository.BotRepository; import com.launchly.bot.repository.BotUserRepository; -import com.launchly.bot.repository.FlowSchemaRepository; import com.launchly.bot.service.BotDialogStateService; +import com.launchly.bot.service.BotUserProvisioningService; import com.launchly.bot.service.FlowEngineService; -import com.launchly.bot.telegram.TelegramBotManager; +import com.launchly.bot.service.SystemBotAuthService; +import com.launchly.bot.telegram.TelegramClientProvider; import com.launchly.broadcast.entity.BroadcastCampaign; import com.launchly.broadcast.repository.BroadcastCampaignRepository; -import com.launchly.billing.service.PlanLimitService; -import com.launchly.common.utils.EncryptionUtil; -import com.launchly.crm.service.CrmService; -import com.launchly.analytics.service.AnalyticsService; -import org.telegram.telegrambots.meta.api.methods.GetUserProfilePhotos; -import org.telegram.telegrambots.meta.api.methods.GetFile; -import org.telegram.telegrambots.meta.api.objects.UserProfilePhotos; -import com.cloudinary.Cloudinary; -import org.springframework.web.client.RestTemplate; -import org.telegram.telegrambots.meta.api.objects.PhotoSize; -import org.telegram.telegrambots.meta.api.objects.File; -import com.launchly.bot.engine.validator.BotInputValidator; -import com.launchly.bot.engine.callstack.BotCallStackManager; -import com.launchly.bot.engine.callstack.CallStackFrame; -import com.launchly.common.utils.MessageUtils; -import org.springframework.context.annotation.Lazy; +import com.launchly.common.utils.SanitizationUtil; import lombok.extern.slf4j.Slf4j; -import com.launchly.auth.service.AuthService; -import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.telegram.telegrambots.meta.api.methods.groupadministration.ApproveChatJoinRequest; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.generics.TelegramClient; -import org.springframework.beans.factory.annotation.Value; -import java.time.Duration; -import java.util.*; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @Slf4j @Service public class FlowEngineServiceImpl implements FlowEngineService { - @Value("${telegram.system-bot-token:}") - private String systemBotToken; - private final BotRepository botRepository; private final BotUserRepository botUserRepository; - private final FlowSchemaRepository flowSchemaRepository; private final BotDialogStateService stateService; private final ObjectMapper objectMapper; private final Map executors; - private final PlanLimitService planLimitService; private final StringRedisTemplate redisTemplate; - private final BroadcastCampaignRepository campaignRepository; - private final TelegramBotManager botManager; - private final EncryptionUtil encryptionUtil; - private final CrmService crmService; - private final Cloudinary cloudinary; + private final BroadcastCampaignRepository broadcastCampaignRepository; + private final TelegramClientProvider telegramClientProvider; private final AnalyticsService analyticsService; - private final AuthService authService; private final BotInputValidator inputValidator; private final BotCallStackManager callStackManager; - private final MessageUtils messageUtils; - private static final String SCHEMA_KEY = "launchly:bot:schema:%d"; - private static final Duration SCHEMA_TTL = Duration.ofMinutes(30); + private final SystemBotAuthService systemBotAuthService; + private final BotUserProvisioningService botUserProvisioningService; + private final FlowSchemaCache schemaCache; + private final BotMessagePersister botMessagePersister; + private final FlowNodeRouter router; public FlowEngineServiceImpl(BotRepository botRepository, BotUserRepository botUserRepository, - FlowSchemaRepository flowSchemaRepository, BotDialogStateService stateService, ObjectMapper objectMapper, List nodeExecutors, - PlanLimitService planLimitService, StringRedisTemplate redisTemplate, - BroadcastCampaignRepository campaignRepository, - @Lazy TelegramBotManager botManager, - EncryptionUtil encryptionUtil, - @Lazy CrmService crmService, - Cloudinary cloudinary, + BroadcastCampaignRepository broadcastCampaignRepository, + @Lazy TelegramClientProvider telegramClientProvider, AnalyticsService analyticsService, - @Lazy AuthService authService, BotInputValidator inputValidator, BotCallStackManager callStackManager, - MessageUtils messageUtils) { + SystemBotAuthService systemBotAuthService, + BotUserProvisioningService botUserProvisioningService, + FlowSchemaCache schemaCache, + BotMessagePersister botMessagePersister, + FlowNodeRouter router) { this.botRepository = botRepository; this.botUserRepository = botUserRepository; - this.flowSchemaRepository = flowSchemaRepository; this.stateService = stateService; this.objectMapper = objectMapper; - this.planLimitService = planLimitService; this.redisTemplate = redisTemplate; - this.campaignRepository = campaignRepository; - this.botManager = botManager; - this.encryptionUtil = encryptionUtil; - this.crmService = crmService; - this.cloudinary = cloudinary; + this.broadcastCampaignRepository = broadcastCampaignRepository; + this.telegramClientProvider = telegramClientProvider; this.analyticsService = analyticsService; - this.authService = authService; this.inputValidator = inputValidator; this.callStackManager = callStackManager; - this.messageUtils = messageUtils; + this.systemBotAuthService = systemBotAuthService; + this.botUserProvisioningService = botUserProvisioningService; + this.schemaCache = schemaCache; + this.botMessagePersister = botMessagePersister; + this.router = router; this.executors = new EnumMap<>(NodeType.class); nodeExecutors.forEach(e -> executors.put(e.getType(), e)); } @@ -122,8 +107,8 @@ public void processUpdate(Long botId, Update update, TelegramClient client) { return; } - if (botId.equals(-1L)) { - handleSystemBotUpdate(update, client); + if (BotConstants.SYSTEM_BOT_ID.equals(botId)) { + systemBotAuthService.handleSystemBotUpdate(update, client); return; } @@ -133,8 +118,42 @@ public void processUpdate(Long botId, Update update, TelegramClient client) { return; } - BotUser botUser = getOrCreateBotUser(bot, update, telegramUserId, client); - if (isAutomationPaused(botUser)) { + String chatType = "private"; + String chatTitle = ""; + Long incomingChatId = telegramUserId; + if (update.hasMessage() && update.getMessage().getChat() != null) { + incomingChatId = update.getMessage().getChatId(); + chatType = update.getMessage().getChat().getType(); + if (update.getMessage().getChat().getTitle() != null) { + chatTitle = update.getMessage().getChat().getTitle(); + } + } else if (update.hasCallbackQuery() && update.getCallbackQuery().getMessage() != null && update.getCallbackQuery().getMessage().getChat() != null) { + incomingChatId = update.getCallbackQuery().getMessage().getChatId(); + chatType = update.getCallbackQuery().getMessage().getChat().getType(); + if (update.getCallbackQuery().getMessage().getChat().getTitle() != null) { + chatTitle = update.getCallbackQuery().getMessage().getChat().getTitle(); + } + } else if (update.hasChannelPost() && update.getChannelPost().getChat() != null) { + incomingChatId = update.getChannelPost().getChatId(); + chatType = update.getChannelPost().getChat().getType(); + if (update.getChannelPost().getChat().getTitle() != null) { + chatTitle = update.getChannelPost().getChat().getTitle(); + } + } + + BotResponseMode responseMode = bot.getResponseMode() != null ? bot.getResponseMode() : BotResponseMode.ALL; + boolean isPrivate = "private".equalsIgnoreCase(chatType); + if (responseMode == BotResponseMode.PRIVATE_ONLY && !isPrivate) { + log.info("Bot {} is set to PRIVATE_ONLY, skipping update from chat type {}", botId, chatType); + return; + } + if (responseMode == BotResponseMode.GROUPS_ONLY && isPrivate) { + log.info("Bot {} is set to GROUPS_ONLY, skipping private update from user {}", botId, telegramUserId); + return; + } + + BotUser botUser = botUserProvisioningService.getOrCreateBotUser(bot, update, telegramUserId, client); + if (stateService.isAutomationPaused(botUser)) { log.info("Automation is paused for user {}, skipping processUpdate", botUser.getId()); return; } @@ -142,10 +161,40 @@ public void processUpdate(Long botId, Update update, TelegramClient client) { analyticsService.logEvent(botId, botUser, AnalyticsEventType.USER_ACTIVITY, update.hasCallbackQuery() ? "CALLBACK" : "MESSAGE"); if (update.hasCallbackQuery()) { String callbackData = update.getCallbackQuery().getData(); - String buttonLabel = resolveButtonLabel(botId, callbackData); + String buttonLabel = router.resolveButtonLabel(botId, callbackData); analyticsService.logEvent(botId, botUser, AnalyticsEventType.CLICK, buttonLabel); } + Long channelChatId = null; + String channelTitle = ""; + String inviteLinkUrl = null; + String inviteLinkName = null; + if (update.hasChatJoinRequest() && update.getChatJoinRequest().getChat() != null) { + var joinReq = update.getChatJoinRequest(); + channelChatId = joinReq.getChat().getId(); + if (joinReq.getChat().getTitle() != null) { + channelTitle = joinReq.getChat().getTitle(); + } + if (joinReq.getInviteLink() != null) { + inviteLinkUrl = joinReq.getInviteLink().getInviteLink(); + inviteLinkName = joinReq.getInviteLink().getName(); + } + } + + stateService.setSessionData(botId, telegramUserId, "chat_type", chatType != null ? chatType : "private"); + stateService.setSessionData(botId, telegramUserId, "chat_id", incomingChatId != null ? String.valueOf(incomingChatId) : String.valueOf(telegramUserId)); + stateService.setSessionData(botId, telegramUserId, "chat_title", chatTitle != null ? chatTitle : ""); + if (channelChatId != null) { + stateService.setSessionData(botId, telegramUserId, "joined_chat_id", String.valueOf(channelChatId)); + stateService.setSessionData(botId, telegramUserId, "joined_chat_title", channelTitle != null ? channelTitle : ""); + if (inviteLinkUrl != null) { + stateService.setSessionData(botId, telegramUserId, "invite_link", inviteLinkUrl); + } + if (inviteLinkName != null) { + stateService.setSessionData(botId, telegramUserId, "invite_name", inviteLinkName); + } + } + if (update.hasMessage() && update.getMessage().hasText() && update.getMessage().getText().trim().startsWith("/start")) { stateService.clearActiveCampaignId(botId, telegramUserId); @@ -158,70 +207,165 @@ public void processUpdate(Long botId, Update update, TelegramClient client) { } Long executingBotId = callStackManager.getExecutingBotId(botId, telegramUserId); - List nodes; - List edges; + FlowSchemaData schemaData; if (!executingBotId.equals(botId)) { - FlowSchema schema = getSchema(executingBotId); - if (schema == null) { - log.warn("No flow schema found for executing bot {}", executingBotId); - return; - } - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); + schemaData = loadBotSchema(executingBotId); } else { Long campaignId = stateService.getActiveCampaignId(botId, telegramUserId).orElse(null); - if (campaignId != null) { - BroadcastCampaign campaign = campaignRepository.findById(campaignId).orElse(null); - if (campaign != null) { - nodes = objectMapper.readValue(campaign.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(campaign.getEdges(), new TypeReference<>() {}); - } else { - FlowSchema schema = getSchema(botId); - if (schema == null) { - log.warn("No flow schema found for bot {}", botId); - return; - } - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - } - } else { - FlowSchema schema = getSchema(botId); - if (schema == null) { - log.warn("No flow schema found for bot {}", botId); - return; - } - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); + schemaData = campaignId != null ? loadCampaignSchema(campaignId) : null; + if (schemaData == null) { + schemaData = loadBotSchema(botId); } } - if (nodes.isEmpty()) { - log.warn("Empty flow schema for bot {}", executingBotId); + if (schemaData == null || schemaData.nodes().isEmpty()) { + log.warn("Empty or missing flow schema for executing bot {}", executingBotId); return; } + List nodes = schemaData.nodes(); + List edges = schemaData.edges(); + String dcKey = "launchly:bot:data_collection:" + botId + ":" + telegramUserId; + if (update.hasChatJoinRequest()) { + var joinReq = update.getChatJoinRequest(); + String channelIdStr = joinReq.getChat() != null ? String.valueOf(joinReq.getChat().getId()) : null; + String inviteLinkStr = joinReq.getInviteLink() != null ? joinReq.getInviteLink().getInviteLink() : null; + + FlowNode joinNode = router.findMatchingJoinRequestNode(nodes, channelIdStr, inviteLinkStr); + + boolean autoApprove = true; + if (joinNode != null && joinNode.data() != null) { + Object autoApproveObj = joinNode.data().get("autoApprove"); + if (autoApproveObj instanceof Boolean b) { + autoApprove = b; + } else if (autoApproveObj instanceof String s) { + autoApprove = Boolean.parseBoolean(s); + } + } + + if (autoApprove && channelChatId != null) { + try { + client.execute(ApproveChatJoinRequest.builder() + .chatId(String.valueOf(channelChatId)) + .userId(telegramUserId) + .build()); + log.info("Auto-approved join request for user {} in chat {}", telegramUserId, channelChatId); + } catch (Exception e) { + log.warn("Failed to approve join request for user {} in chat {}: {}", telegramUserId, channelChatId, e.getMessage()); + } + } + + if (joinNode != null) { + redisTemplate.delete(dcKey); + stateService.clearActiveCampaignId(botId, telegramUserId); + callStackManager.clear(botId, telegramUserId); + callStackManager.setExecutingBotId(botId, telegramUserId, botId); + String currentNodeId = joinNode.id(); + stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); + botUser.setCurrentNodeId(currentNodeId); + botUser = botUserRepository.save(botUser); + executeNodeLoop(botId, botUser, telegramUserId, currentNodeId, nodes, edges, botId, update, client); + return; + } + } + + if (update.hasMessage() && update.getMessage().hasText()) { + String msgText = update.getMessage().getText().trim(); + if (msgText.startsWith("/")) { + FlowNode commandNode = router.findMatchingCommandNode(nodes, msgText); + if (commandNode != null) { + redisTemplate.delete(dcKey); + stateService.clearActiveCampaignId(botId, telegramUserId); + callStackManager.clear(botId, telegramUserId); + callStackManager.setExecutingBotId(botId, telegramUserId, botId); + String currentNodeId = commandNode.id(); + stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); + botUser.setCurrentNodeId(currentNodeId); + botUser = botUserRepository.save(botUser); + executeNodeLoop(botId, botUser, telegramUserId, currentNodeId, nodes, edges, botId, update, client); + return; + } + } + } + String dcStateStr = redisTemplate.opsForValue().get(dcKey); if (dcStateStr != null && !dcStateStr.trim().isEmpty()) { DataCollectionState dcState = objectMapper.readValue(dcStateStr, DataCollectionState.class); if (System.currentTimeMillis() > dcState.getExpiresAt()) { redisTemplate.delete(dcKey); - String timeoutNodeId = findTargetNodeId(edges, dcState.getNodeId(), "timeout"); + String timeoutNodeId = router.findTargetNodeId(edges, dcState.getNodeId(), "timeout"); if (timeoutNodeId == null) { - timeoutNodeId = findTargetNodeId(edges, dcState.getNodeId(), "next"); + timeoutNodeId = router.findTargetNodeId(edges, dcState.getNodeId(), "next"); } stateService.setCurrentNodeId(botId, telegramUserId, timeoutNodeId); botUser.setCurrentNodeId(timeoutNodeId); botUser = botUserRepository.save(botUser); - } else if (update.hasMessage() && update.getMessage().hasText()) { - String text = update.getMessage().getText().trim(); - boolean isValid = inputValidator.validate(text, dcState.getReplyType()); - if (isValid) { - saveCustomField(botUser, dcState.getSaveToField(), text); + } else { + boolean isImageExpected = "image".equalsIgnoreCase(dcState.getReplyType()) || "photo".equalsIgnoreCase(dcState.getReplyType()); + boolean isValid = false; + String valueToSave = null; + String customErrorMessage = null; + + if (isImageExpected) { + if (update.hasMessage() && update.getMessage().hasPhoto()) { + var photos = update.getMessage().getPhoto(); + if (photos != null && !photos.isEmpty()) { + var largestPhoto = photos.get(photos.size() - 1); + if (largestPhoto.getFileSize() != null && largestPhoto.getFileSize() > BotInputValidator.MAX_IMAGE_SIZE_BYTES) { + isValid = false; + customErrorMessage = inputValidator.getImageSizeErrorMessage(); + } else { + valueToSave = largestPhoto.getFileId(); + isValid = true; + } + } + } else if (update.hasMessage() && update.getMessage().hasDocument()) { + var doc = update.getMessage().getDocument(); + if (doc != null) { + String mime = doc.getMimeType(); + String fileName = doc.getFileName() != null ? doc.getFileName().toLowerCase() : ""; + boolean isImageMime = mime != null && mime.toLowerCase().startsWith("image/"); + boolean isImageExt = fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") + || fileName.endsWith(".png") || fileName.endsWith(".webp") + || fileName.endsWith(".bmp") || fileName.endsWith(".gif"); + + if (!isImageMime && !isImageExt) { + isValid = false; + customErrorMessage = inputValidator.getImageFormatErrorMessage(); + } else if (doc.getFileSize() != null && doc.getFileSize() > BotInputValidator.MAX_IMAGE_SIZE_BYTES) { + isValid = false; + customErrorMessage = inputValidator.getImageSizeErrorMessage(); + } else { + valueToSave = doc.getFileId(); + isValid = true; + } + } + } else { + isValid = false; + customErrorMessage = inputValidator.getValidationErrorMessage(dcState.getReplyType()); + } + } else if (update.hasMessage() && update.getMessage().hasText()) { + String text = update.getMessage().getText().trim(); + if (text.length() > BotInputValidator.MAX_TEXT_LENGTH) { + isValid = false; + customErrorMessage = inputValidator.getTextLengthErrorMessage(); + } else if (inputValidator.validate(text, dcState.getReplyType())) { + valueToSave = text; + isValid = true; + } + } + + if (isValid && valueToSave != null) { + saveCustomField(botUser, dcState.getSaveToField(), valueToSave); + if (isImageExpected) { + botUser.setPhotoUrl(valueToSave); + botUser = botUserRepository.save(botUser); + } redisTemplate.delete(dcKey); - String successNodeId = findTargetNodeId(edges, dcState.getNodeId(), "reply"); + String successNodeId = router.findTargetNodeId(edges, dcState.getNodeId(), "reply"); if (successNodeId == null) { - successNodeId = findTargetNodeId(edges, dcState.getNodeId(), "next"); + successNodeId = router.findTargetNodeId(edges, dcState.getNodeId(), "next"); } stateService.setCurrentNodeId(botId, telegramUserId, successNodeId); botUser.setCurrentNodeId(successNodeId); @@ -231,331 +375,37 @@ public void processUpdate(Long botId, Update update, TelegramClient client) { if (retriesLeft >= 0) { dcState.setRetryCount(retriesLeft); redisTemplate.opsForValue().set(dcKey, objectMapper.writeValueAsString(dcState)); - inputValidator.sendValidationErrorMessage(telegramUserId.toString(), dcState.getReplyType(), client); + if (customErrorMessage != null) { + inputValidator.sendCustomErrorMessage(telegramUserId.toString(), customErrorMessage, client); + } else { + inputValidator.sendValidationErrorMessage(telegramUserId.toString(), dcState.getReplyType(), client); + } return; } else { redisTemplate.delete(dcKey); - String timeoutNodeId = findTargetNodeId(edges, dcState.getNodeId(), "timeout"); + String timeoutNodeId = router.findTargetNodeId(edges, dcState.getNodeId(), "timeout"); if (timeoutNodeId == null) { - timeoutNodeId = findTargetNodeId(edges, dcState.getNodeId(), "next"); + timeoutNodeId = router.findTargetNodeId(edges, dcState.getNodeId(), "next"); } stateService.setCurrentNodeId(botId, telegramUserId, timeoutNodeId); botUser.setCurrentNodeId(timeoutNodeId); botUser = botUserRepository.save(botUser); } } - } else { - return; - } - } - String currentNodeId = resolveCurrentNodeId(botId, telegramUserId, botUser, nodes); - - int maxIterations = 50; - int iteration = 0; - - while (currentNodeId != null && iteration < maxIterations) { - iteration++; - - FlowNode currentNode = findNodeById(nodes, currentNodeId); - if (currentNode == null) { - log.error("Node {} not found in schema for bot {}", currentNodeId, botId); - stateService.setCurrentNodeId(botId, telegramUserId, null); - botUser.setCurrentNodeId(null); - botUserRepository.save(botUser); - break; - } - - if (currentNode.type() == NodeType.START_AUTOMATION) { - Object targetIdObj = currentNode.data().get("targetBotId"); - Long targetBotId = null; - if (targetIdObj instanceof Number) { - targetBotId = ((Number) targetIdObj).longValue(); - } else if (targetIdObj instanceof String) { - try { - targetBotId = Long.parseLong((String) targetIdObj); - } catch (NumberFormatException e) { - } - } - - String returnNodeId = findTargetNodeId(edges, currentNodeId, "next"); - - if (targetBotId != null && !targetBotId.equals(executingBotId)) { - Long campaignId = stateService.getActiveCampaignId(botId, telegramUserId).orElse(null); - CallStackFrame frame = new CallStackFrame(executingBotId, returnNodeId, campaignId); - callStackManager.push(botId, telegramUserId, frame); - - executingBotId = targetBotId; - callStackManager.setExecutingBotId(botId, telegramUserId, executingBotId); - stateService.clearActiveCampaignId(botId, telegramUserId); - - FlowSchema schema = getSchema(executingBotId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - - String botStartNodeId = nodes.stream() - .filter(n -> n.type() == NodeType.START) - .findFirst() - .map(FlowNode::id) - .orElse(null); - - if (botStartNodeId != null) { - currentNodeId = botStartNodeId; - stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); - botUser.setCurrentNodeId(currentNodeId); - botUser = botUserRepository.save(botUser); - continue; - } - } - } - - currentNodeId = returnNodeId; - stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); - botUser.setCurrentNodeId(currentNodeId); - botUser = botUserRepository.save(botUser); - continue; - } - - NodeExecutor executor = executors.get(currentNode.type()); - if (executor == null) { - log.error("No executor for node type {} in bot {}", currentNode.type(), botId); - break; - } - - String nextNodeId = executor.execute(currentNode, edges, botUser, update, client); - boolean isFirstIterationCallback = (iteration == 1 && update != null && update.hasCallbackQuery()); - if (!isFirstIterationCallback && (currentNode.type() == NodeType.MESSAGE || currentNode.type() == NodeType.BUTTON)) { - saveBotNodeMessage(botId, botUser, currentNode); - } - - if (nextNodeId == null) { - CallStackFrame poppedFrame = callStackManager.pop(botId, telegramUserId); - if (poppedFrame != null) { - executingBotId = poppedFrame.getExecutingBotId(); - callStackManager.setExecutingBotId(botId, telegramUserId, executingBotId); - - if (!executingBotId.equals(botId)) { - FlowSchema schema = getSchema(executingBotId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - } - } else { - Long campaignId = poppedFrame.getCampaignId(); - if (campaignId != null) { - stateService.setActiveCampaignId(botId, telegramUserId, campaignId); - BroadcastCampaign campaign = campaignRepository.findById(campaignId).orElse(null); - if (campaign != null) { - nodes = objectMapper.readValue(campaign.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(campaign.getEdges(), new TypeReference<>() {}); - } else { - FlowSchema schema = getSchema(botId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - } - } - } else { - stateService.clearActiveCampaignId(botId, telegramUserId); - FlowSchema schema = getSchema(botId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - } - } - } - - nextNodeId = poppedFrame.getReturnNodeId(); - } else { - boolean hasOutgoingEdges = edges.stream().anyMatch(e -> e.source().equals(currentNode.id())); - boolean isWaitingForInput = stateService.getExpectedInput(botId, telegramUserId).isPresent(); - if ((currentNode.type() == NodeType.END || !hasOutgoingEdges) && !isWaitingForInput) { - stateService.clearActiveCampaignId(botId, telegramUserId); - stateService.setCurrentNodeId(botId, telegramUserId, null); - botUser.setCurrentNodeId(null); - } else { - stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); - botUser.setCurrentNodeId(currentNodeId); - } - break; - } } - - currentNodeId = nextNodeId; - stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); - botUser.setCurrentNodeId(currentNodeId); } - try { - botUserRepository.save(botUser); - } catch (Exception e) { - log.warn("Failed to persist final botUser state for user {}: {}", botUser.getId(), e.getMessage()); - } + String currentNodeId = router.resolveCurrentNodeId(botId, telegramUserId, botUser, nodes, stateService); + executeNodeLoop(botId, botUser, telegramUserId, currentNodeId, nodes, edges, executingBotId, update, client); } catch (Exception e) { log.error("Error processing update for bot {}: {}", botId, e.getMessage(), e); } } - private Long extractTelegramUserId(Update update) { - if (update.hasMessage() && update.getMessage().getFrom() != null) { - return update.getMessage().getFrom().getId(); - } - if (update.hasCallbackQuery() && update.getCallbackQuery().getFrom() != null) { - return update.getCallbackQuery().getFrom().getId(); - } - return null; - } - - private BotUser getOrCreateBotUser(Bot bot, Update update, Long telegramUserId, TelegramClient telegramClient) { - BotUser botUser = botUserRepository.findByTelegramIdAndBotId(telegramUserId, bot.getId()) - .orElseGet(() -> { - planLimitService.checkBotUserLimit(bot.getId()); - String username = null; - String firstName = null; - String lastName = null; - - if (update.hasMessage() && update.getMessage().getFrom() != null) { - var from = update.getMessage().getFrom(); - username = from.getUserName(); - firstName = from.getFirstName(); - lastName = from.getLastName(); - } else if (update.hasCallbackQuery() && update.getCallbackQuery().getFrom() != null) { - var from = update.getCallbackQuery().getFrom(); - username = from.getUserName(); - firstName = from.getFirstName(); - lastName = from.getLastName(); - } - - BotUser newUser = BotUser.builder() - .telegramId(telegramUserId) - .username(username) - .firstName(firstName) - .lastName(lastName) - .bot(bot) - .build(); - return botUserRepository.save(newUser); - }); - - if ((botUser.getPhotoUrl() == null || botUser.getPhotoUrl().startsWith("https://api.telegram.org/")) && telegramClient != null) { - fetchAndSetPhotoUrl(botUser, bot, telegramClient); - } - - return botUser; - } - - private void fetchAndSetPhotoUrl(BotUser botUser, Bot bot, TelegramClient telegramClient) { - try { - GetUserProfilePhotos getUserProfilePhotos = GetUserProfilePhotos.builder() - .userId(botUser.getTelegramId()) - .limit(1) - .build(); - UserProfilePhotos photos = telegramClient.execute(getUserProfilePhotos); - if (photos != null && photos.getTotalCount() > 0 && photos.getPhotos() != null && !photos.getPhotos().isEmpty()) { - List photoSizes = photos.getPhotos().get(0); - PhotoSize largest = photoSizes.stream() - .max(Comparator.comparingInt(size -> size.getWidth() * size.getHeight())) - .orElse(null); - if (largest != null) { - GetFile getFile = GetFile.builder() - .fileId(largest.getFileId()) - .build(); - File file = telegramClient.execute(getFile); - if (file != null && file.getFilePath() != null) { - String botToken = encryptionUtil.decrypt(bot.getTelegramToken()); - String fileUrl = "https://api.telegram.org/file/bot" + botToken + "/" + file.getFilePath(); - try { - RestTemplate restTemplate = new RestTemplate(); - byte[] fileBytes = restTemplate.getForObject(fileUrl, byte[].class); - if (fileBytes != null && fileBytes.length > 0) { - Map params = Map.of( - "folder", "launchly/" + bot.getUser().getId() + "/contacts", - "transformation", "c_limit,w_400,h_400,q_auto,f_auto" - ); - Map result = cloudinary.uploader().upload(fileBytes, params); - String secureUrl = (String) result.get("secure_url"); - botUser.setPhotoUrl(secureUrl); - } else { - botUser.setPhotoUrl(fileUrl); - } - } catch (Exception uploadEx) { - log.warn("Failed to upload profile photo to Cloudinary: {}", uploadEx.getMessage()); - botUser.setPhotoUrl(fileUrl); - } - botUserRepository.save(botUser); - log.debug("Fetched profile photo for user {}", botUser.getTelegramId()); - } - } - } - } catch (Exception e) { - log.warn("Could not fetch profile photo for user {}: {}", botUser.getTelegramId(), e.getMessage()); - } - } - - private String resolveCurrentNodeId(Long botId, Long telegramUserId, BotUser botUser, List nodes) { - Optional redisNodeId = stateService.getCurrentNodeId(botId, telegramUserId); - if (redisNodeId.isPresent() && !redisNodeId.get().trim().isEmpty()) { - return redisNodeId.get(); - } - - if (botUser.getCurrentNodeId() != null && !botUser.getCurrentNodeId().trim().isEmpty()) { - stateService.setCurrentNodeId(botId, telegramUserId, botUser.getCurrentNodeId()); - return botUser.getCurrentNodeId(); - } - - return nodes.stream() - .filter(n -> n.type() == NodeType.START || n.type() == NodeType.START_BROADCAST) - .findFirst() - .map(FlowNode::id) - .orElse(null); - } - - private FlowNode findNodeById(List nodes, String nodeId) { - return nodes.stream() - .filter(n -> n.id().equals(nodeId)) - .findFirst() - .orElse(null); - } - - private record CachedSchema(Long id, int version, String nodes, String edges) {} - - private FlowSchema getSchema(Long botId) { - String key = String.format(SCHEMA_KEY, botId); - String cached = redisTemplate.opsForValue().get(key); - - if (cached != null) { - try { - CachedSchema cachedSchema = objectMapper.readValue(cached, CachedSchema.class); - FlowSchema schema = new FlowSchema(); - schema.setId(cachedSchema.id()); - schema.setVersion(cachedSchema.version()); - schema.setNodes(cachedSchema.nodes()); - schema.setEdges(cachedSchema.edges()); - return schema; - } catch (Exception e) { - log.error("Failed to deserialize cached schema for bot {}: {}", botId, e.getMessage()); - } - } - - Optional schemaOpt = flowSchemaRepository.findByBotId(botId); - if (schemaOpt.isEmpty()) { - return null; - } - - FlowSchema schema = schemaOpt.get(); - try { - CachedSchema cachedSchema = new CachedSchema(schema.getId(), schema.getVersion(), schema.getNodes(), schema.getEdges()); - redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(cachedSchema), SCHEMA_TTL); - } catch (Exception e) { - log.error("Failed to serialize schema for bot {}: {}", botId, e.getMessage()); - } - - return schema; - } - @Override public void runFlow(Long botId, BotUser botUser, String startNodeId, Long campaignId) { - if (botUser == null || isAutomationPaused(botUser)) { + if (botUser == null || stateService.isAutomationPaused(botUser)) { if (botUser != null) { log.info("Automation is paused for user {}, skipping runFlow", botUser.getId()); } @@ -563,7 +413,7 @@ public void runFlow(Long botId, BotUser botUser, String startNodeId, Long campai } try { Long telegramUserId = botUser.getTelegramId(); - TelegramClient client = botManager.getTelegramClient(botId); + TelegramClient client = telegramClientProvider.getTelegramClient(botId); if (client == null) { log.warn("Telegram client not found for bot {}", botId); return; @@ -571,7 +421,6 @@ public void runFlow(Long botId, BotUser botUser, String startNodeId, Long campai callStackManager.clear(botId, telegramUserId); callStackManager.setExecutingBotId(botId, telegramUserId, botId); - Long executingBotId = botId; if (campaignId != null) { stateService.setActiveCampaignId(botId, telegramUserId, campaignId); @@ -579,327 +428,242 @@ public void runFlow(Long botId, BotUser botUser, String startNodeId, Long campai stateService.clearActiveCampaignId(botId, telegramUserId); } - List nodes; - List edges; - if (campaignId != null) { - BroadcastCampaign campaign = campaignRepository.findById(campaignId).orElse(null); - if (campaign != null) { - nodes = objectMapper.readValue(campaign.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(campaign.getEdges(), new TypeReference<>() {}); - } else { - return; - } - } else { - FlowSchema schema = getSchema(botId); - if (schema == null) { - return; - } - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); + FlowSchemaData schemaData = campaignId != null ? loadCampaignSchema(campaignId) : null; + if (schemaData == null) { + schemaData = loadBotSchema(botId); + } + if (schemaData == null || schemaData.nodes().isEmpty()) { + return; } - String currentNodeId = startNodeId; - int maxIterations = 50; - int iteration = 0; - - while (currentNodeId != null && iteration < maxIterations) { - iteration++; - - FlowNode currentNode = findNodeById(nodes, currentNodeId); - if (currentNode == null) { - log.error("Node {} not found in schema", currentNodeId); - stateService.setCurrentNodeId(botId, telegramUserId, null); - botUser.setCurrentNodeId(null); - botUserRepository.save(botUser); - break; - } - - if (currentNode.type() == NodeType.START_AUTOMATION) { - Object targetIdObj = currentNode.data().get("targetBotId"); - Long targetBotId = null; - if (targetIdObj instanceof Number) { - targetBotId = ((Number) targetIdObj).longValue(); - } else if (targetIdObj instanceof String) { - try { - targetBotId = Long.parseLong((String) targetIdObj); - } catch (NumberFormatException e) { - } - } - - String returnNodeId = findTargetNodeId(edges, currentNodeId, "next"); - - if (targetBotId != null && !targetBotId.equals(executingBotId)) { - Long currentCampaignId = stateService.getActiveCampaignId(botId, telegramUserId).orElse(null); - CallStackFrame frame = new CallStackFrame(executingBotId, returnNodeId, currentCampaignId); - callStackManager.push(botId, telegramUserId, frame); - - executingBotId = targetBotId; - callStackManager.setExecutingBotId(botId, telegramUserId, executingBotId); - stateService.clearActiveCampaignId(botId, telegramUserId); + executeNodeLoop(botId, botUser, telegramUserId, startNodeId, schemaData.nodes(), schemaData.edges(), botId, null, client); - FlowSchema schema = getSchema(executingBotId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); + } catch (Exception e) { + log.error("Error running flow for bot {}: {}", botId, e.getMessage(), e); + } + } - String botStartNodeId = nodes.stream() - .filter(n -> n.type() == NodeType.START) - .findFirst() - .map(FlowNode::id) - .orElse(null); + private void executeNodeLoop(Long botId, + BotUser botUser, + Long telegramUserId, + String startNodeId, + List initialNodes, + List initialEdges, + Long initialExecutingBotId, + Update update, + TelegramClient client) throws Exception { + String currentNodeId = startNodeId; + List nodes = initialNodes; + List edges = initialEdges; + Long executingBotId = initialExecutingBotId; + int maxIterations = BotConstants.MAX_FLOW_ITERATIONS; + int iteration = 0; + + while (currentNodeId != null && iteration < maxIterations) { + iteration++; + + FlowNode currentNode = router.findNodeById(nodes, currentNodeId); + if (currentNode == null) { + log.error("Node {} not found in schema for bot {}", currentNodeId, botId); + stateService.setCurrentNodeId(botId, telegramUserId, null); + botUser.setCurrentNodeId(null); + botUserRepository.save(botUser); + break; + } - if (botStartNodeId != null) { - currentNodeId = botStartNodeId; - stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); - botUser.setCurrentNodeId(currentNodeId); - botUser = botUserRepository.save(botUser); - continue; - } - } + if (currentNode.type() == NodeType.START_AUTOMATION) { + Object targetIdObj = currentNode.data().get("targetBotId"); + Long targetBotId = null; + if (targetIdObj instanceof Number) { + targetBotId = ((Number) targetIdObj).longValue(); + } else if (targetIdObj instanceof String) { + try { + targetBotId = Long.parseLong((String) targetIdObj); + } catch (NumberFormatException e) { + log.warn("Failed to parse targetBotId from string: {}", targetIdObj); } - - currentNodeId = returnNodeId; - stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); - botUser.setCurrentNodeId(currentNodeId); - botUser = botUserRepository.save(botUser); - continue; } - NodeExecutor executor = executors.get(currentNode.type()); - if (executor == null) { - log.error("No executor for node type {}", currentNode.type()); - break; - } + String returnNodeId = router.findTargetNodeId(edges, currentNodeId, "next"); - String nextNodeId = executor.execute(currentNode, edges, botUser, null, client); - if (currentNode.type() == NodeType.MESSAGE || currentNode.type() == NodeType.BUTTON) { - saveBotNodeMessage(botId, botUser, currentNode); - } + if (targetBotId != null && !targetBotId.equals(executingBotId)) { + Long campaignId = stateService.getActiveCampaignId(botId, telegramUserId).orElse(null); + CallStackFrame frame = new CallStackFrame(executingBotId, returnNodeId, campaignId); + callStackManager.push(botId, telegramUserId, frame); - if (nextNodeId == null) { - CallStackFrame poppedFrame = callStackManager.pop(botId, telegramUserId); - if (poppedFrame != null) { - executingBotId = poppedFrame.getExecutingBotId(); - callStackManager.setExecutingBotId(botId, telegramUserId, executingBotId); + executingBotId = targetBotId; + callStackManager.setExecutingBotId(botId, telegramUserId, executingBotId); + stateService.clearActiveCampaignId(botId, telegramUserId); - if (!executingBotId.equals(botId)) { - FlowSchema schema = getSchema(executingBotId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - } - } else { - Long originalCampaignId = poppedFrame.getCampaignId(); - if (originalCampaignId != null) { - stateService.setActiveCampaignId(botId, telegramUserId, originalCampaignId); - BroadcastCampaign campaign = campaignRepository.findById(originalCampaignId).orElse(null); - if (campaign != null) { - nodes = objectMapper.readValue(campaign.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(campaign.getEdges(), new TypeReference<>() {}); - } else { - FlowSchema schema = getSchema(botId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - } - } - } else { - stateService.clearActiveCampaignId(botId, telegramUserId); - FlowSchema schema = getSchema(botId); - if (schema != null) { - nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); - } - } - } + FlowSchemaData targetSchemaData = loadBotSchema(executingBotId); + if (targetSchemaData != null) { + nodes = targetSchemaData.nodes(); + edges = targetSchemaData.edges(); - nextNodeId = poppedFrame.getReturnNodeId(); - } else { - boolean hasOutgoingEdges = edges.stream().anyMatch(e -> e.source().equals(currentNode.id())); - boolean isWaitingForInput = stateService.getExpectedInput(botId, telegramUserId).isPresent(); - if ((currentNode.type() == NodeType.END || !hasOutgoingEdges) && !isWaitingForInput) { - stateService.clearActiveCampaignId(botId, telegramUserId); - stateService.setCurrentNodeId(botId, telegramUserId, null); - botUser.setCurrentNodeId(null); - } else { + String botStartNodeId = nodes.stream() + .filter(n -> n.type() == NodeType.START) + .findFirst() + .map(FlowNode::id) + .orElse(null); + + if (botStartNodeId != null) { + currentNodeId = botStartNodeId; stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); botUser.setCurrentNodeId(currentNodeId); + continue; } - break; } } - currentNodeId = nextNodeId; + currentNodeId = returnNodeId; stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); botUser.setCurrentNodeId(currentNodeId); + continue; } - try { - botUserRepository.save(botUser); - } catch (Exception e) { - log.warn("Failed to persist final botUser state for user {}: {}", botUser.getId(), e.getMessage()); + NodeExecutor executor = executors.get(currentNode.type()); + if (executor == null) { + log.error("No executor for node type {} in bot {}", currentNode.type(), botId); + break; } - } catch (Exception e) { - log.error("Error running flow for bot {}: {}", botId, e.getMessage(), e); - } - } - @SuppressWarnings("unchecked") - private void saveBotNodeMessage(Long botId, BotUser botUser, FlowNode node) { - try { - Map data = node.data(); - if (data == null) return; + String nextNodeId = executor.execute(currentNode, edges, botUser, update, client); + boolean isFirstIterationCallback = (iteration == 1 && update != null && update.hasCallbackQuery()); + if (!isFirstIterationCallback && (currentNode.type() == NodeType.MESSAGE || currentNode.type() == NodeType.BUTTON)) { + botMessagePersister.saveBotNodeMessage(botId, botUser, currentNode); + } - Object blocksObj = data.get("blocks"); - if (blocksObj instanceof List blocks && !blocks.isEmpty()) { - for (Object blockObj : blocks) { - if (blockObj instanceof Map block) { - String type = (String) block.get("type"); - if ("text".equals(type) || "data_collection".equals(type)) { - StringBuilder text = new StringBuilder(); - Object t = block.get("text"); - if (t instanceof String s && !s.isBlank()) { - text.append(s); - } - Object btns = block.get("buttons"); - if (btns instanceof List btnList) { - for (Object btn : btnList) { - if (btn instanceof Map b) { - Object lbl = b.get("label"); - if (lbl instanceof String l) { text.append(" [").append(l).append("]"); } - } - } - } - if (text.length() > 0) { - crmService.saveBotMessage(botId, botUser.getId(), text.toString(), null, null); - } - } else if ("image".equals(type)) { - String imageUrl = (String) block.get("imageUrl"); - if (imageUrl != null && !imageUrl.trim().isEmpty()) { - StringBuilder caption = new StringBuilder(); - Object t = block.get("text"); - if (t instanceof String s && !s.isBlank()) { - caption.append(s); - } else { - Object cap = block.get("caption"); - if (cap instanceof String c && !c.isBlank()) { caption.append(c); } - } - crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[Image]", imageUrl, "IMAGE"); - } - } else if ("video".equals(type)) { - String videoUrl = (String) block.get("videoUrl"); - if (videoUrl != null && !videoUrl.trim().isEmpty()) { - StringBuilder caption = new StringBuilder(); - Object t = block.get("text"); - if (t instanceof String s && !s.isBlank()) { - caption.append(s); - } else { - Object cap = block.get("caption"); - if (cap instanceof String c && !c.isBlank()) { caption.append(c); } - } - crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[Video]", videoUrl, "VIDEO"); - } - } else if ("audio".equals(type)) { - String audioUrl = (String) block.get("audioUrl"); - if (audioUrl != null && !audioUrl.trim().isEmpty()) { - StringBuilder caption = new StringBuilder(); - Object t = block.get("text"); - if (t instanceof String s && !s.isBlank()) { - caption.append(s); - } else { - Object cap = block.get("caption"); - if (cap instanceof String c && !c.isBlank()) { caption.append(c); } - } - crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[Audio]", audioUrl, "AUDIO"); - } - } else if ("file".equals(type)) { - String fileUrl = (String) block.get("fileUrl"); - if (fileUrl != null && !fileUrl.trim().isEmpty()) { - String fileName = (String) block.get("fileName"); - StringBuilder caption = new StringBuilder(); - if (fileName != null && !fileName.isBlank()) { caption.append(fileName); } - Object t = block.get("text"); - if (t instanceof String s && !s.isBlank()) { - if (caption.length() > 0) caption.append(": "); - caption.append(s); - } - crmService.saveBotMessage(botId, botUser.getId(), caption.length() > 0 ? caption.toString() : "[File]", fileUrl, "FILE"); - } + if (nextNodeId == null) { + CallStackFrame poppedFrame = callStackManager.pop(botId, telegramUserId); + if (poppedFrame != null) { + executingBotId = poppedFrame.getExecutingBotId(); + callStackManager.setExecutingBotId(botId, telegramUserId, executingBotId); + + if (!executingBotId.equals(botId)) { + FlowSchemaData subBotData = loadBotSchema(executingBotId); + if (subBotData != null) { + nodes = subBotData.nodes(); + edges = subBotData.edges(); } - } - } - } else { - String text = (String) data.getOrDefault("text", ""); - String imageUrl = (String) data.get("imageUrl"); - List buttonsList = (List) data.get("buttons"); - - StringBuilder content = new StringBuilder(); - if (text != null && !text.isBlank()) { - content.append(text); - } - if (buttonsList != null) { - for (Object btn : buttonsList) { - if (btn instanceof Map b) { - Object lbl = b.get("label"); - if (lbl instanceof String l) { content.append(" [").append(l).append("]"); } + } else { + Long campaignId = poppedFrame.getCampaignId(); + FlowSchemaData campaignData = campaignId != null ? loadCampaignSchema(campaignId) : null; + if (campaignData != null) { + stateService.setActiveCampaignId(botId, telegramUserId, campaignId); + nodes = campaignData.nodes(); + edges = campaignData.edges(); + } else { + stateService.clearActiveCampaignId(botId, telegramUserId); + FlowSchemaData mainBotData = loadBotSchema(botId); + if (mainBotData != null) { + nodes = mainBotData.nodes(); + edges = mainBotData.edges(); + } } } - } - if (imageUrl != null && !imageUrl.trim().isEmpty()) { - if (content.length() == 0) { - content.append("📷 Photo"); + nextNodeId = poppedFrame.getReturnNodeId(); + } else { + boolean hasOutgoingEdges = edges.stream().anyMatch(e -> e.source().equals(currentNode.id())); + boolean isWaitingForInput = stateService.getExpectedInput(botId, telegramUserId).isPresent(); + if ((currentNode.type() == NodeType.END || !hasOutgoingEdges) && !isWaitingForInput) { + stateService.clearActiveCampaignId(botId, telegramUserId); + stateService.setCurrentNodeId(botId, telegramUserId, null); + botUser.setCurrentNodeId(null); + } else { + stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); + botUser.setCurrentNodeId(currentNodeId); } - crmService.saveBotMessage(botId, botUser.getId(), content.toString(), imageUrl, "image"); - } else if (content.length() > 0) { - crmService.saveBotMessage(botId, botUser.getId(), content.toString(), null, null); + break; } } + + currentNodeId = nextNodeId; + stateService.setCurrentNodeId(botId, telegramUserId, currentNodeId); + botUser.setCurrentNodeId(currentNodeId); + } + + try { + botUserRepository.save(botUser); } catch (Exception e) { - log.warn("Failed to save bot message in CRM for bot {}: {}", botId, e.getMessage()); + log.error("Failed to persist final botUser state for user {}: {}", botUser.getId(), e.getMessage(), e); + throw e; } } - @SuppressWarnings("unchecked") - private String resolveButtonLabel(Long botId, String callbackData) { - if (callbackData == null || callbackData.isBlank()) return ""; - try { - FlowSchema schema = getSchema(botId); - if (schema == null || schema.getNodes() == null) return callbackData; + private record FlowSchemaData(List nodes, List edges) {} + private FlowSchemaData loadBotSchema(Long botId) { + if (botId == null) return null; + FlowSchema schema = schemaCache.getSchema(botId); + if (schema == null) { + return null; + } + try { List nodes = objectMapper.readValue(schema.getNodes(), new TypeReference<>() {}); - for (FlowNode node : nodes) { - if (node.data() == null) continue; - Object blocksObj = node.data().get("blocks"); - if (blocksObj instanceof List blocks) { - for (Object blockObj : blocks) { - if (blockObj instanceof Map block) { - Object btnsObj = block.get("buttons"); - if (btnsObj instanceof List buttons) { - for (Object btnObj : buttons) { - if (btnObj instanceof Map btn) { - Object targetNodeId = btn.get("targetNodeId"); - if (callbackData.equals(targetNodeId)) { - Object label = btn.get("label"); - return label != null ? label.toString() : callbackData; - } - } - } - } + List edges = objectMapper.readValue(schema.getEdges(), new TypeReference<>() {}); + return new FlowSchemaData(nodes, edges); + } catch (Exception e) { + log.error("Failed to parse bot schema for bot {}: {}", botId, e.getMessage()); + return null; + } + } + + private FlowSchemaData loadCampaignSchema(Long campaignId) { + if (campaignId == null) return null; + try { + return broadcastCampaignRepository.findById(campaignId) + .map(campaign -> { + try { + List nodes = objectMapper.readValue(campaign.getNodes(), new TypeReference<>() {}); + List edges = objectMapper.readValue(campaign.getEdges(), new TypeReference<>() {}); + return new FlowSchemaData(nodes, edges); + } catch (Exception e) { + log.error("Failed to parse campaign schema for campaign {}: {}", campaignId, e.getMessage()); + return null; } - } - } - } + }) + .orElse(null); } catch (Exception e) { - log.warn("Failed to resolve button label for callback data {} in bot {}: {}", callbackData, botId, e.getMessage()); + log.warn("Failed to fetch campaign {}: {}", campaignId, e.getMessage()); + return null; } - return callbackData; + } + + private Long extractTelegramUserId(Update update) { + if (update.hasMessage() && update.getMessage().getFrom() != null) { + return update.getMessage().getFrom().getId(); + } + if (update.hasCallbackQuery() && update.getCallbackQuery().getFrom() != null) { + return update.getCallbackQuery().getFrom().getId(); + } + if (update.hasChatJoinRequest() && update.getChatJoinRequest().getUser() != null) { + return update.getChatJoinRequest().getUser().getId(); + } + return null; } @SuppressWarnings("unchecked") private void saveCustomField(BotUser botUser, String fieldName, String fieldValue) { - if (fieldName == null || fieldName.trim().isEmpty()) return; + if (fieldName == null || fieldName.trim().isEmpty() || fieldValue == null) return; + String cleanFieldName = fieldName.trim(); + if (cleanFieldName.length() > 50) { + cleanFieldName = cleanFieldName.substring(0, 50); + } + cleanFieldName = cleanFieldName.replaceAll("[^a-zA-Z0-9_\\-\\.]", ""); + if (cleanFieldName.isEmpty()) return; + + String sanitizedValue = fieldValue.trim(); + if (sanitizedValue.length() > BotInputValidator.MAX_TEXT_LENGTH) { + sanitizedValue = sanitizedValue.substring(0, BotInputValidator.MAX_TEXT_LENGTH); + } + sanitizedValue = SanitizationUtil.sanitizeForTelegram(sanitizedValue); + try { + if ("photo_url".equalsIgnoreCase(cleanFieldName) || "photo".equalsIgnoreCase(cleanFieldName) + || "image".equalsIgnoreCase(cleanFieldName) || "avatar".equalsIgnoreCase(cleanFieldName)) { + botUser.setPhotoUrl(sanitizedValue); + } Map metaMap = new HashMap<>(); if (botUser.getMetadata() != null && !botUser.getMetadata().trim().isEmpty()) { metaMap = objectMapper.readValue(botUser.getMetadata(), Map.class); @@ -908,7 +672,9 @@ private void saveCustomField(BotUser botUser, String fieldName, String fieldValu if (customFields == null) { customFields = new HashMap<>(); } - customFields.put(fieldName, fieldValue); + if (customFields.size() < 100 || customFields.containsKey(cleanFieldName)) { + customFields.put(cleanFieldName, sanitizedValue); + } metaMap.put("customFields", customFields); botUser.setMetadata(objectMapper.writeValueAsString(metaMap)); botUserRepository.save(botUser); @@ -916,140 +682,4 @@ private void saveCustomField(BotUser botUser, String fieldName, String fieldValu log.error("Failed to save custom field: {}", e.getMessage(), e); } } - - private String findTargetNodeId(List edges, String sourceNodeId, String sourceHandle) { - return edges.stream() - .filter(e -> e.source().equals(sourceNodeId) && sourceHandle.equals(e.sourceHandle())) - .findFirst() - .map(FlowEdge::target) - .orElse(null); - } - - private void handleSystemBotUpdate(Update update, TelegramClient client) { - if (!update.hasMessage() || !update.getMessage().hasText()) { - return; - } - - String text = update.getMessage().getText().trim(); - Long chatId = update.getMessage().getChatId(); - - if (text.startsWith("/start")) { - String token = null; - if (text.contains(" ")) { - token = text.substring(text.indexOf(" ") + 1).trim(); - } - - if (token == null || token.isBlank()) { - String welcomeMsg = messageUtils.getMessageWithDefault( - "bot.system.welcome", - "Welcome to Launchly! Please use the website to log in or link your account."); - sendSystemBotMessage(chatId, welcomeMsg, client); - return; - } - - try { - String telegramUsername = update.getMessage().getFrom().getUserName(); - Long telegramUserId = update.getMessage().getFrom().getId(); - - String telegramName = update.getMessage().getFrom().getFirstName(); - if (update.getMessage().getFrom().getLastName() != null) { - telegramName += " " + update.getMessage().getFrom().getLastName(); - } - - String telegramPhotoUrl = null; - try { - GetUserProfilePhotos getUserProfilePhotos = GetUserProfilePhotos.builder() - .userId(telegramUserId) - .limit(1) - .build(); - UserProfilePhotos photos = client.execute(getUserProfilePhotos); - if (photos != null && photos.getTotalCount() > 0 && photos.getPhotos() != null && !photos.getPhotos().isEmpty()) { - List photoSizes = photos.getPhotos().get(0); - PhotoSize largest = photoSizes.stream() - .max(Comparator.comparingInt(size -> size.getWidth() * size.getHeight())) - .orElse(null); - if (largest != null) { - GetFile getFile = GetFile.builder() - .fileId(largest.getFileId()) - .build(); - File file = client.execute(getFile); - if (file != null && file.getFilePath() != null) { - telegramPhotoUrl = "https://api.telegram.org/file/bot" + systemBotToken + "/" + file.getFilePath(); - } - } - } - } catch (Exception ex) { - log.warn("Failed to fetch profile photo for telegram auth: {}", ex.getMessage()); - } - - boolean isSubscription = authService.handleTelegramAuth(token, telegramUserId, telegramUsername, telegramName, telegramPhotoUrl); - - if (isSubscription) { - String optinMsg = messageUtils.getMessageWithDefault( - "bot.system.optin_success", - "You are successfully opted-in. Now you are able to receive 'Launchly Official' bot notifications.\nIf you want to stop notifications in Telegram you have to opt-out.\nVisit 'My Telegram for Notifications' section in Settings -> Notifications."); - sendSystemBotMessage(chatId, optinMsg, client); - } else { - String authSuccessMsg = messageUtils.getMessageWithDefault( - "bot.system.auth_success", - "Hi! You successfully signed up/logged in with Telegram. Thank you! You can now return to the website."); - sendSystemBotMessage(chatId, authSuccessMsg, client); - } - } catch (Exception e) { - log.error("Failed to process system bot auth: {}", e.getMessage()); - String authFailedMsg = messageUtils.getMessageWithDefault( - "bot.system.auth_failed", - "Failed to authorize: " + e.getMessage(), - e.getMessage()); - sendSystemBotMessage(chatId, authFailedMsg, client); - } - } else { - String useWebsiteMsg = messageUtils.getMessageWithDefault( - "bot.system.use_website", - "Please use the website to log in or link your account."); - sendSystemBotMessage(chatId, useWebsiteMsg, client); - } - } - - private void sendSystemBotMessage(Long chatId, String text, TelegramClient client) { - try { - SendMessage message = SendMessage.builder() - .chatId(chatId.toString()) - .text(text) - .build(); - client.execute(message); - } catch (Exception e) { - log.error("Failed to send message from system bot: {}", e.getMessage()); - } - } - - private boolean isAutomationPaused(BotUser botUser) { - if (botUser == null) return false; - String metadata = botUser.getMetadata(); - if (metadata == null || metadata.isBlank() || "{}".equals(metadata)) return false; - try { - Map meta = objectMapper.readValue(metadata, new TypeReference>() {}); - if (meta != null && Boolean.TRUE.equals(meta.get("paused"))) { - Object pausedUntilObj = meta.get("pausedUntil"); - if (pausedUntilObj instanceof Number) { - long pausedUntil = ((Number) pausedUntilObj).longValue(); - if (System.currentTimeMillis() > pausedUntil) { - return false; - } - } else if (pausedUntilObj instanceof String) { - try { - long pausedUntil = Long.parseLong((String) pausedUntilObj); - if (System.currentTimeMillis() > pausedUntil) { - return false; - } - } catch (NumberFormatException e) { - } - } - return true; - } - } catch (Exception e) { - log.warn("Failed to check if automation is paused for user {}: {}", botUser.getId(), e.getMessage()); - } - return false; - } } diff --git a/backend/src/main/java/com/launchly/bot/service/impl/SystemBotAuthServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/SystemBotAuthServiceImpl.java new file mode 100644 index 00000000..3087d907 --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/impl/SystemBotAuthServiceImpl.java @@ -0,0 +1,138 @@ +package com.launchly.bot.service.impl; + +import com.launchly.auth.service.AuthService; +import com.launchly.bot.constant.TelegramConstants; +import com.launchly.bot.service.SystemBotAuthService; +import com.launchly.common.utils.MessageUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.telegram.telegrambots.meta.api.methods.GetFile; +import org.telegram.telegrambots.meta.api.methods.GetUserProfilePhotos; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.objects.File; +import org.telegram.telegrambots.meta.api.objects.PhotoSize; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.UserProfilePhotos; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import org.springframework.context.annotation.Lazy; + +import java.util.Comparator; +import java.util.List; + +@Slf4j +@Service +public class SystemBotAuthServiceImpl implements SystemBotAuthService { + + @Value("${telegram.system-bot-token:}") + private String systemBotToken; + + private final AuthService authService; + private final MessageUtils messageUtils; + + public SystemBotAuthServiceImpl(@Lazy AuthService authService, MessageUtils messageUtils) { + this.authService = authService; + this.messageUtils = messageUtils; + } + + @Override + public void handleSystemBotUpdate(Update update, TelegramClient client) { + if (!update.hasMessage() || !update.getMessage().hasText()) { + return; + } + + String text = update.getMessage().getText().trim(); + Long chatId = update.getMessage().getChatId(); + + if (text.startsWith("/start")) { + String token = null; + if (text.contains(" ")) { + token = text.substring(text.indexOf(" ") + 1).trim(); + } + + if (token == null || token.isBlank()) { + String welcomeMsg = messageUtils.getMessageWithDefault( + "bot.system.welcome", + "Welcome to Launchly! Please use the website to log in or link your account."); + sendSystemBotMessage(chatId, welcomeMsg, client); + return; + } + + try { + String telegramUsername = update.getMessage().getFrom().getUserName(); + Long telegramUserId = update.getMessage().getFrom().getId(); + + String telegramName = update.getMessage().getFrom().getFirstName(); + if (update.getMessage().getFrom().getLastName() != null) { + telegramName += " " + update.getMessage().getFrom().getLastName(); + } + + String telegramPhotoUrl = null; + try { + GetUserProfilePhotos getUserProfilePhotos = GetUserProfilePhotos.builder() + .userId(telegramUserId) + .limit(1) + .build(); + UserProfilePhotos photos = client.execute(getUserProfilePhotos); + if (photos != null && photos.getTotalCount() > 0 && photos.getPhotos() != null && !photos.getPhotos().isEmpty()) { + List photoSizes = photos.getPhotos().get(0); + PhotoSize largest = photoSizes.stream() + .max(Comparator.comparingInt(size -> size.getWidth() * size.getHeight())) + .orElse(null); + if (largest != null) { + GetFile getFile = GetFile.builder() + .fileId(largest.getFileId()) + .build(); + File file = client.execute(getFile); + if (file != null && file.getFilePath() != null) { + telegramPhotoUrl = String.format(TelegramConstants.FILE_DOWNLOAD_URL_TEMPLATE, systemBotToken, file.getFilePath()); + } + } + } + } catch (Exception ex) { + log.warn("Failed to fetch profile photo for telegram auth: {}", ex.getMessage()); + } + + boolean isSubscription = authService.handleTelegramAuth(token, telegramUserId, telegramUsername, telegramName, telegramPhotoUrl); + + if (isSubscription) { + String optinMsg = messageUtils.getMessageWithDefault( + "bot.system.optin_success", + "You are successfully opted-in. Now you are able to receive 'Launchly Official' bot notifications.\nIf you want to stop notifications in Telegram you have to opt-out.\nVisit 'My Telegram for Notifications' section in Settings -> Notifications."); + sendSystemBotMessage(chatId, optinMsg, client); + } else { + String authSuccessMsg = messageUtils.getMessageWithDefault( + "bot.system.auth_success", + "Hi! You successfully signed up/logged in with Telegram. Thank you! You can now return to the website."); + sendSystemBotMessage(chatId, authSuccessMsg, client); + } + } catch (Exception e) { + log.error("Failed to process system bot auth: {}", e.getMessage()); + String authFailedMsg = messageUtils.getMessageWithDefault( + "bot.system.auth_failed", + "Failed to authorize: " + e.getMessage(), + e.getMessage()); + sendSystemBotMessage(chatId, authFailedMsg, client); + } + } else { + String useWebsiteMsg = messageUtils.getMessageWithDefault( + "bot.system.use_website", + "Please use the website to log in or link your account."); + sendSystemBotMessage(chatId, useWebsiteMsg, client); + } + } + + private void sendSystemBotMessage(Long chatId, String text, TelegramClient client) { + try { + SendMessage message = SendMessage.builder() + .chatId(chatId.toString()) + .text(text) + .build(); + client.execute(message); + } catch (Exception e) { + log.error("Failed to send message from system bot: {}", e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/service/impl/TeamServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/TeamServiceImpl.java index 6b492d3b..037cde7f 100644 --- a/backend/src/main/java/com/launchly/bot/service/impl/TeamServiceImpl.java +++ b/backend/src/main/java/com/launchly/bot/service/impl/TeamServiceImpl.java @@ -8,6 +8,7 @@ import com.launchly.bot.entity.Bot; import com.launchly.bot.entity.BotInvitation; import com.launchly.bot.entity.BotMember; +import com.launchly.bot.entity.WorkspaceRole; import com.launchly.bot.repository.BotInvitationRepository; import com.launchly.bot.repository.BotMemberRepository; import com.launchly.bot.repository.BotRepository; @@ -17,8 +18,11 @@ import com.launchly.billing.entity.SubscriptionStatus; import com.launchly.billing.repository.PlanRepository; import com.launchly.billing.repository.SubscriptionRepository; +import com.launchly.common.constant.CacheConstants; import com.launchly.common.exception.AppException; import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +39,16 @@ public class TeamServiceImpl implements TeamService { private final BotInvitationRepository botInvitationRepository; private final SubscriptionRepository subscriptionRepository; private final PlanRepository planRepository; + private final CacheManager cacheManager; + + private void evictBotsCache(Long userId) { + if (userId != null && cacheManager != null) { + Cache cache = cacheManager.getCache(CacheConstants.BOTS); + if (cache != null) { + cache.evict(userId); + } + } + } @Override @Transactional(readOnly = true) @@ -58,7 +72,7 @@ public List getTeamMembers(Long botId, Long currentUserId) { bot.getUser().getEmail(), bot.getUser().getName(), bot.getUser().getAvatar(), - "Owner", + WorkspaceRole.OWNER.getValue(), true, true, false, @@ -121,13 +135,7 @@ public List getTeamMembers(Long botId, Long currentUserId) { } private int getRolePrivilege(String role) { - if (role == null) return 0; - return switch (role.toLowerCase()) { - case "admin" -> 3; - case "editor" -> 2; - case "viewer" -> 1; - default -> 0; - }; + return WorkspaceRole.resolvePrivilege(role); } @Override @@ -226,6 +234,7 @@ public TeamMemberResponse updateMember(Long botId, Long userId, UpdateMemberRequ firstUpdated = saved; } } + evictBotsCache(userId); return new TeamMemberResponse( firstUpdated.getId(), @@ -260,6 +269,7 @@ public void removeMember(Long botId, Long userId, Long currentUserId) { for (BotMember member : members) { botMemberRepository.delete(member); } + evictBotsCache(userId); } @Override @@ -303,17 +313,33 @@ public void acceptInvitation(Long invitationId, Long currentUserId) { invite.setAccepted(true); botInvitationRepository.save(invite); - Optional existingMember = botMemberRepository.findByBotIdAndUserId(invite.getBot().getId(), user.getId()); - if (existingMember.isEmpty()) { - BotMember member = BotMember.builder() - .bot(invite.getBot()) - .user(user) - .role(invite.getRole()) - .inboxSeat(invite.isInboxSeat()) - .billingPermission(invite.isBillingPermission()) - .build(); - botMemberRepository.save(member); + User owner = invite.getBot().getUser(); + List ownerBots = botRepository.findAllByUserId(owner.getId()); + if (ownerBots.isEmpty()) { + ownerBots = List.of(invite.getBot()); } + + for (Bot b : ownerBots) { + Optional existingMember = botMemberRepository.findByBotIdAndUserId(b.getId(), user.getId()); + if (existingMember.isEmpty()) { + BotMember member = BotMember.builder() + .bot(b) + .user(user) + .role(invite.getRole()) + .inboxSeat(invite.isInboxSeat()) + .billingPermission(invite.isBillingPermission()) + .build(); + botMemberRepository.save(member); + } else { + BotMember member = existingMember.get(); + member.setRole(invite.getRole()); + member.setInboxSeat(invite.isInboxSeat()); + member.setBillingPermission(invite.isBillingPermission()); + botMemberRepository.save(member); + } + } + + evictBotsCache(user.getId()); } @Override @@ -397,6 +423,9 @@ public void transferOwnership(Long botId, Long newOwnerUserId, Long currentUserI } }); } + + evictBotsCache(oldOwner.getId()); + evictBotsCache(newOwnerUserId); } @Override @@ -418,6 +447,7 @@ public void leaveBot(Long botId, Long currentUserId) { botMemberRepository.delete(member); } } + evictBotsCache(currentUserId); } } diff --git a/backend/src/main/java/com/launchly/bot/service/impl/TelegramWebhookServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/TelegramWebhookServiceImpl.java index af408447..b845b876 100644 --- a/backend/src/main/java/com/launchly/bot/service/impl/TelegramWebhookServiceImpl.java +++ b/backend/src/main/java/com/launchly/bot/service/impl/TelegramWebhookServiceImpl.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.launchly.bot.service.BotModerationService; import com.launchly.bot.service.FlowEngineService; import com.launchly.bot.service.TelegramWebhookService; import com.launchly.bot.telegram.TelegramBotManager; @@ -14,7 +15,6 @@ import org.springframework.stereotype.Service; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.generics.TelegramClient; - import java.time.Duration; @Slf4j @@ -26,10 +26,15 @@ public class TelegramWebhookServiceImpl implements TelegramWebhookService { private final TelegramBotManager telegramBotManager; private final RateLimitService rateLimitService; private final StringRedisTemplate stringRedisTemplate; + private final BotModerationService moderationService; private static final ObjectMapper TELEGRAM_MAPPER = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final Duration UPDATE_DEDUP_TTL = Duration.ofSeconds(120); + private static final int TG_USER_RATE_LIMIT_MAX = 30; + private static final Duration TG_USER_RATE_LIMIT_WINDOW = Duration.ofMinutes(1); + @Override public void processWebhookUpdate(Long botId, String rawUpdate) { TelegramClient client = telegramBotManager.getTelegramClient(botId); @@ -42,7 +47,7 @@ public void processWebhookUpdate(Long botId, String rawUpdate) { if (update.getUpdateId() != null) { String dedupKey = "telegram:update:" + botId + ":" + update.getUpdateId(); - Boolean isNew = stringRedisTemplate.opsForValue().setIfAbsent(dedupKey, "1", Duration.ofSeconds(120)); + Boolean isNew = stringRedisTemplate.opsForValue().setIfAbsent(dedupKey, "1", UPDATE_DEDUP_TTL); if (Boolean.FALSE.equals(isNew)) { log.info("Duplicate Telegram update ignored: botId={}, updateId={}", botId, update.getUpdateId()); return; @@ -52,12 +57,17 @@ public void processWebhookUpdate(Long botId, String rawUpdate) { Long telegramUserId = extractTelegramUserId(update); if (telegramUserId != null) { String rateKey = "rate:tg:user:" + botId + ":" + telegramUserId; - if (!rateLimitService.isAllowed(rateKey, 30, Duration.ofMinutes(1))) { + if (!rateLimitService.isAllowed(rateKey, TG_USER_RATE_LIMIT_MAX, TG_USER_RATE_LIMIT_WINDOW)) { log.warn("Rate limit exceeded for Telegram user {} in bot {}", telegramUserId, botId); return; } } + if (moderationService != null && moderationService.processUpdateModeration(botId, update, client)) { + log.info("Webhook update for bot {} was moderated and intercepted", botId); + return; + } + flowEngineService.processUpdate(botId, update, client); } catch (AppException e) { throw e; @@ -77,6 +87,9 @@ private Long extractTelegramUserId(Update update) { if (update.hasChannelPost() && update.getChannelPost().getFrom() != null) { return update.getChannelPost().getFrom().getId(); } + if (update.hasChatJoinRequest() && update.getChatJoinRequest().getUser() != null) { + return update.getChatJoinRequest().getUser().getId(); + } return null; } } diff --git a/backend/src/main/java/com/launchly/bot/service/impl/TemplateServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/TemplateServiceImpl.java index 6c9b0217..764a50e7 100644 --- a/backend/src/main/java/com/launchly/bot/service/impl/TemplateServiceImpl.java +++ b/backend/src/main/java/com/launchly/bot/service/impl/TemplateServiceImpl.java @@ -1,9 +1,11 @@ package com.launchly.bot.service.impl; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.launchly.common.constant.CacheConstants; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; import com.launchly.auth.entity.User; import com.launchly.auth.service.UserQueryService; +import com.launchly.bot.constant.BotConstants; import com.launchly.bot.dto.request.CreateTemplateRequest; import com.launchly.bot.dto.request.UpdateTemplateRequest; import com.launchly.bot.dto.response.TemplateResponse; @@ -51,7 +53,7 @@ public class TemplateServiceImpl implements TemplateService { private final BroadcastCampaignRepository broadcastCampaignRepository; private final TagRepository tagRepository; private final EncryptionUtil encryptionUtil; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Value("${app.frontend-url:http://localhost:5173}") private String frontendUrl; @@ -62,7 +64,7 @@ public TemplateResponse createTemplate(CreateTemplateRequest request, Long userI User creator = userQueryService.getUserOrThrow(userId); Bot bot = null; - String sourceBotName = "Автоматизація"; + String sourceBotName = BotConstants.DEFAULT_AUTOMATION_NAME; String sourceBotDescription = ""; String nodes = "[]"; String edges = "[]"; @@ -71,7 +73,7 @@ public TemplateResponse createTemplate(CreateTemplateRequest request, Long userI if (request.botId() != null) { bot = botRepository.findById(request.botId()).orElse(null); if (bot != null) { - if (!bot.getUser().getId().equals(userId) && !botMemberRepository.existsByBotIdAndUserId(request.botId(), userId)) { + if (!bot.getUser().getId().equals(userId) && !botMemberRepository.existsByBotOwnerIdAndUserId(bot.getUser().getId(), userId)) { throw new AppException(HttpStatus.FORBIDDEN, "bot.error.access_denied"); } @@ -94,6 +96,7 @@ public TemplateResponse createTemplate(CreateTemplateRequest request, Long userI try { schemaJson = objectMapper.writeValueAsString(payload); } catch (Exception e) { + log.warn("Failed to serialize template flow schema: {}", e.getMessage()); schemaJson = "{\"nodes\":\"[]\",\"edges\":\"[]\"}"; } @@ -138,6 +141,7 @@ public TemplateResponse createTemplate(CreateTemplateRequest request, Long userI resolvedFieldCount = selectedFieldsList.size(); } } catch (Exception e) { + log.warn("Failed to filter custom fields data for template: {}", e.getMessage()); customFieldsData = bot.getCustomFieldsData(); resolvedFieldCount = fieldIds.size(); } @@ -212,7 +216,7 @@ public TemplateResponse createTemplate(CreateTemplateRequest request, Long userI @Cacheable(value = "templates", key = "#shareCode") public TemplateResponse getTemplateByShareCode(String shareCode) { AccountTemplate template = accountTemplateRepository.findByShareCode(shareCode) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Template not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "template.error.not_found")); return toTemplateResponse(template); } @@ -283,18 +287,18 @@ public TemplateResponse updateTemplate(String shareCode, UpdateTemplateRequest r @Override @Transactional @Caching(evict = { - @CacheEvict(value = "bots", key = "#userId"), - @CacheEvict(value = "flow_schemas", allEntries = true) + @CacheEvict(value = CacheConstants.BOTS, key = "#userId"), + @CacheEvict(value = CacheConstants.FLOW_SCHEMAS, allEntries = true) }) public void installTemplate(String shareCode, Long targetBotId, Long userId) { AccountTemplate template = accountTemplateRepository.findByShareCode(shareCode) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Template not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "template.error.not_found")); User user = userQueryService.getUserOrThrow(userId); Bot targetBot = null; if (targetBotId != null) { Optional botOpt = botRepository.findById(targetBotId); - if (botOpt.isPresent() && (botOpt.get().getUser().getId().equals(userId) || botMemberRepository.existsByBotIdAndUserId(targetBotId, userId))) { + if (botOpt.isPresent() && (botOpt.get().getUser().getId().equals(userId) || botMemberRepository.existsByBotOwnerIdAndUserId(botOpt.get().getUser().getId(), userId))) { targetBot = botOpt.get(); } } @@ -313,7 +317,7 @@ public void installTemplate(String shareCode, Long targetBotId, Long userId) { .avatar(template.getAvatarUrl()) .templateName(template.getName()) .template(true) - .telegramToken(encryptionUtil.encrypt("0000000000:dummyTokenPlaceholderForNoBotConfig")) + .telegramToken(encryptionUtil.encrypt(BotConstants.DUMMY_TOKEN_PLACEHOLDER)) .active(false) .user(user) .customFieldsData(customFields) diff --git a/backend/src/main/java/com/launchly/bot/service/impl/UserAvatarServiceImpl.java b/backend/src/main/java/com/launchly/bot/service/impl/UserAvatarServiceImpl.java new file mode 100644 index 00000000..30a15f1a --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/service/impl/UserAvatarServiceImpl.java @@ -0,0 +1,156 @@ +package com.launchly.bot.service.impl; + +import com.cloudinary.Cloudinary; +import com.launchly.bot.constant.TelegramConstants; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.UserAvatarService; +import com.launchly.bot.telegram.TelegramBotManager; +import com.launchly.common.utils.EncryptionUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.telegram.telegrambots.meta.api.methods.GetFile; +import org.telegram.telegrambots.meta.api.methods.GetUserProfilePhotos; +import org.telegram.telegrambots.meta.api.objects.File; +import org.telegram.telegrambots.meta.api.objects.PhotoSize; +import org.telegram.telegrambots.meta.api.objects.UserProfilePhotos; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +public class UserAvatarServiceImpl implements UserAvatarService { + + private static final String CLOUDINARY_AVATAR_TRANSFORMATION = "c_limit,w_400,h_400,q_auto,f_auto"; + + private final BotRepository botRepository; + private final BotUserRepository botUserRepository; + private final EncryptionUtil encryptionUtil; + private final Cloudinary cloudinary; + private final TelegramBotManager botManager; + private final HttpClient httpClient; + + public UserAvatarServiceImpl(BotRepository botRepository, + BotUserRepository botUserRepository, + EncryptionUtil encryptionUtil, + Cloudinary cloudinary, + @Lazy TelegramBotManager botManager, + HttpClient httpClient) { + this.botRepository = botRepository; + this.botUserRepository = botUserRepository; + this.encryptionUtil = encryptionUtil; + this.cloudinary = cloudinary; + this.botManager = botManager; + this.httpClient = httpClient; + } + + @Override + public void fetchAndSetPhotoUrl(BotUser botUser) { + if (botUser == null || botUser.getBot() == null) { + return; + } + Long botId = botUser.getBot().getId(); + TelegramClient telegramClient = botManager.getTelegramClient(botId); + if (telegramClient == null) { + return; + } + Bot bot = botRepository.findById(botId).orElse(null); + if (bot == null) { + return; + } + fetchAndSetPhotoUrl(botUser, bot, telegramClient); + } + + @Override + public void fetchAndSetPhotoUrl(BotUser botUser, Bot bot, TelegramClient telegramClient) { + if (botUser == null || bot == null || telegramClient == null) { + return; + } + if (botUser.getTelegramId() == null || botUser.getTelegramId() <= 0) { + return; + } + try { + log.info("Fetching profile photo from Telegram for user {} in bot {}", botUser.getTelegramId(), bot.getName()); + GetUserProfilePhotos getUserProfilePhotos = GetUserProfilePhotos.builder() + .userId(botUser.getTelegramId()) + .limit(1) + .build(); + UserProfilePhotos photos = telegramClient.execute(getUserProfilePhotos); + if (photos == null || photos.getTotalCount() == 0 || photos.getPhotos() == null || photos.getPhotos().isEmpty()) { + log.info("Telegram returned 0 profile photos for user {} (possibly due to privacy settings or no avatar set)", botUser.getTelegramId()); + return; + } + + List photoSizes = photos.getPhotos().getFirst(); + PhotoSize largest = photoSizes.stream() + .max(Comparator.comparingInt(size -> size.getWidth() * size.getHeight())) + .orElse(null); + if (largest == null) { + return; + } + + GetFile getFile = GetFile.builder() + .fileId(largest.getFileId()) + .build(); + File file = telegramClient.execute(getFile); + if (file == null || file.getFilePath() == null) { + log.warn("Could not get file path from Telegram for user {}", botUser.getTelegramId()); + return; + } + + String botToken = encryptionUtil.decrypt(bot.getTelegramToken()); + String fileUrl = String.format(TelegramConstants.FILE_DOWNLOAD_URL_TEMPLATE, botToken, file.getFilePath()); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(fileUrl)) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + byte[] fileBytes = response.statusCode() == HttpStatus.OK.value() ? response.body() : null; + + if (fileBytes != null && fileBytes.length > 0) { + String folder = "launchly/contacts"; + try { + if (bot.getUser() != null && bot.getUser().getId() != null) { + folder = "launchly/" + bot.getUser().getId() + "/contacts"; + } + } catch (Exception ignored) { + } + + Map params = Map.of( + "folder", folder, + "transformation", CLOUDINARY_AVATAR_TRANSFORMATION + ); + Map result = cloudinary.uploader().upload(fileBytes, params); + String secureUrl = (String) result.get("secure_url"); + if (secureUrl != null && !secureUrl.isBlank()) { + botUser.setPhotoUrl(secureUrl); + log.info("Successfully uploaded profile photo to Cloudinary for user {}: {}", botUser.getTelegramId(), secureUrl); + } else { + botUser.setPhotoUrl(fileUrl); + } + } else { + botUser.setPhotoUrl(fileUrl); + } + } catch (Exception uploadEx) { + log.warn("Failed to upload profile photo to Cloudinary for user {}: {}", botUser.getTelegramId(), uploadEx.getMessage()); + botUser.setPhotoUrl(fileUrl); + } + botUserRepository.save(botUser); + } catch (Exception e) { + log.warn("Could not fetch profile photo for user {}: {}", botUser.getTelegramId(), e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/launchly/bot/telegram/BotUpdateHandler.java b/backend/src/main/java/com/launchly/bot/telegram/BotUpdateHandler.java index 98b15401..00a7ca9d 100644 --- a/backend/src/main/java/com/launchly/bot/telegram/BotUpdateHandler.java +++ b/backend/src/main/java/com/launchly/bot/telegram/BotUpdateHandler.java @@ -1,6 +1,7 @@ package com.launchly.bot.telegram; import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.BotModerationService; import com.launchly.bot.service.FlowEngineService; import com.launchly.crm.service.CrmService; import lombok.RequiredArgsConstructor; @@ -18,10 +19,15 @@ public class BotUpdateHandler implements LongPollingSingleThreadUpdateConsumer { private final TelegramClient telegramClient; private final CrmService crmService; private final BotUserRepository botUserRepository; + private final BotModerationService moderationService; @Override public void consume(Update update) { try { + if (moderationService != null && moderationService.processUpdateModeration(botId, update, telegramClient)) { + log.info("Update for bot {} was moderated and intercepted", botId); + return; + } saveIncomingMessageToCrm(update); flowEngineService.processUpdate(botId, update, telegramClient); } catch (Exception e) { diff --git a/backend/src/main/java/com/launchly/bot/telegram/TelegramBotManager.java b/backend/src/main/java/com/launchly/bot/telegram/TelegramBotManager.java index f4ecd2f0..631015ba 100644 --- a/backend/src/main/java/com/launchly/bot/telegram/TelegramBotManager.java +++ b/backend/src/main/java/com/launchly/bot/telegram/TelegramBotManager.java @@ -2,7 +2,10 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import com.launchly.bot.service.BotModerationService; import com.launchly.bot.service.FlowEngineService; +import com.launchly.bot.constant.BotConstants; +import com.launchly.bot.constant.TelegramConstants; import com.launchly.bot.entity.Bot; import com.launchly.bot.repository.BotRepository; import com.launchly.bot.repository.BotUserRepository; @@ -11,26 +14,37 @@ import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.telegram.telegrambots.client.okhttp.OkHttpTelegramClient; import org.telegram.telegrambots.longpolling.TelegramBotsLongPollingApplication; +import org.springframework.web.client.RestTemplate; import org.telegram.telegrambots.meta.generics.TelegramClient; -import java.time.Duration; + import java.util.List; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.locks.ReentrantLock; @Slf4j @Component @RequiredArgsConstructor -public class TelegramBotManager { +public class TelegramBotManager implements TelegramClientProvider { private final BotRepository botRepository; private final BotUserRepository botUserRepository; private final EncryptionUtil encryptionUtil; private final FlowEngineService flowEngineService; private final CrmService crmService; + private final BotModerationService moderationService; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + @Qualifier("taskExecutor") + private final Executor taskExecutor; @Value("${telegram.mode:polling}") private String mode; @@ -43,8 +57,13 @@ public class TelegramBotManager { private final ConcurrentHashMap activeBots = new ConcurrentHashMap<>(); private final ConcurrentHashMap telegramClients = new ConcurrentHashMap<>(); + private final ConcurrentHashMap botLocks = new ConcurrentHashMap<>(); + + private ReentrantLock getBotLock(Long botId) { + return botLocks.computeIfAbsent(botId, k -> new ReentrantLock()); + } - @EventListener(org.springframework.boot.context.event.ApplicationReadyEvent.class) + @EventListener(ApplicationReadyEvent.class) public void init() { if (!"polling".equalsIgnoreCase(mode)) { log.info("Telegram bot manager running in webhook mode, skipping auto-start"); @@ -60,26 +79,28 @@ public void init() { } } - new Thread(() -> { + taskExecutor.execute(() -> { try { List bots = botRepository.findAllByActiveTrue(); log.info("Starting {} active bots in background", bots.size()); - bots.parallelStream().forEach(bot -> { - try { - registerBot(bot); - } catch (Exception e) { - log.error("Failed to start bot {} (id={}): {}", bot.getName(), bot.getId(), e.getMessage()); - } - }); - log.info("Finished starting active bots in background"); + for (Bot bot : bots) { + taskExecutor.execute(() -> { + try { + registerBot(bot); + } catch (Exception e) { + log.error("Failed to start bot {} (id={}): {}", bot.getName(), bot.getId(), e.getMessage()); + } + }); + } + log.info("Dispatched active bots background startup tasks"); } catch (Exception e) { log.error("Error during background bots startup: {}", e.getMessage()); } - }).start(); + }); } - public synchronized void registerBot(Bot bot) { + public void registerBot(Bot bot) { if (bot == null || bot.getId() == null) { return; } @@ -87,10 +108,15 @@ public synchronized void registerBot(Bot bot) { return; } + getBotLock(bot.getId()).lock(); try { + if (activeBots.containsKey(bot.getId())) { + return; + } + String token = encryptionUtil.decrypt(bot.getTelegramToken()); - if (token == null || token.isBlank() || "0000000000:dummyTokenPlaceholderForNoBotConfig".equals(token)) { + if (token == null || token.isBlank() || BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(token)) { log.info("Skipping registration for bot {} (id={}): dummy token placeholder", bot.getName(), bot.getId()); return; } @@ -114,18 +140,12 @@ public synchronized void registerBot(Bot bot) { } } - org.springframework.http.client.SimpleClientHttpRequestFactory factory = new org.springframework.http.client.SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(Duration.ofMillis(1500)); - factory.setReadTimeout(Duration.ofMillis(1500)); - org.springframework.web.client.RestTemplate timeoutRestTemplate = new org.springframework.web.client.RestTemplate(factory); - if (bot.getUsername() == null || bot.getUsername().isBlank()) { try { - String url = "https://api.telegram.org/bot" + token + "/getMe"; - org.springframework.http.ResponseEntity responseEntity = timeoutRestTemplate.getForEntity(url, String.class); + String url = String.format(TelegramConstants.GET_ME_URL_TEMPLATE, token); + ResponseEntity responseEntity = restTemplate.getForEntity(url, String.class); if (responseEntity.getStatusCode().is2xxSuccessful() && responseEntity.getBody() != null) { - ObjectMapper mapper = new ObjectMapper(); - JsonNode root = mapper.readTree(responseEntity.getBody()); + JsonNode root = objectMapper.readTree(responseEntity.getBody()); if (root.has("ok") && root.get("ok").asBoolean()) { JsonNode result = root.get("result"); if (result.has("username")) { @@ -143,37 +163,39 @@ public synchronized void registerBot(Bot bot) { } try { - String deleteWebhookUrl = "https://api.telegram.org/bot" + token + "/deleteWebhook?drop_pending_updates=false"; - timeoutRestTemplate.getForEntity(deleteWebhookUrl, String.class); + String deleteWebhookUrl = String.format(TelegramConstants.DELETE_WEBHOOK_URL_TEMPLATE, token); + restTemplate.getForEntity(deleteWebhookUrl, String.class); } catch (Exception e) { log.debug("Could not call deleteWebhook before polling for bot {}: {}", bot.getId(), e.getMessage()); } TelegramBotsLongPollingApplication pollingApp = new TelegramBotsLongPollingApplication(); BotUpdateHandler handler = new BotUpdateHandler( - bot.getId(), flowEngineService, telegramClient, crmService, botUserRepository); + bot.getId(), flowEngineService, telegramClient, crmService, botUserRepository, moderationService); pollingApp.registerBot(token, handler); activeBots.put(bot.getId(), pollingApp); log.info("Registered bot {} for long polling", bot.getId()); } catch (Exception e) { log.warn("Could not start external long polling for bot {} (offline/test mode): {}", bot.getId(), e.getMessage()); + } finally { + getBotLock(bot.getId()).unlock(); } } - private synchronized void registerSystemBot() { - if (activeBots.containsKey(-1L)) { + private void registerSystemBot() { + if (activeBots.containsKey(BotConstants.SYSTEM_BOT_ID)) { return; } + getBotLock(BotConstants.SYSTEM_BOT_ID).lock(); try { + if (activeBots.containsKey(BotConstants.SYSTEM_BOT_ID)) { + return; + } + try { - String deleteWebhookUrl = "https://api.telegram.org/bot" + systemBotToken + "/deleteWebhook?drop_pending_updates=false"; - org.springframework.web.client.RestTemplate restTemplate = new org.springframework.web.client.RestTemplate(); + String deleteWebhookUrl = String.format(TelegramConstants.DELETE_WEBHOOK_URL_TEMPLATE, systemBotToken); restTemplate.getForEntity(deleteWebhookUrl, String.class); - wait(300); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - log.warn("System bot wait interrupted: {}", ie.getMessage()); } catch (Exception e) { log.warn("Failed to call deleteWebhook before polling for system bot: {}", e.getMessage()); } @@ -181,29 +203,37 @@ private synchronized void registerSystemBot() { TelegramClient telegramClient = new OkHttpTelegramClient(systemBotToken); TelegramBotsLongPollingApplication pollingApp = new TelegramBotsLongPollingApplication(); BotUpdateHandler handler = new BotUpdateHandler( - -1L, flowEngineService, telegramClient, crmService, botUserRepository); + BotConstants.SYSTEM_BOT_ID, flowEngineService, telegramClient, crmService, botUserRepository, moderationService); pollingApp.registerBot(systemBotToken, handler); - activeBots.put(-1L, pollingApp); - telegramClients.put(-1L, telegramClient); + activeBots.put(BotConstants.SYSTEM_BOT_ID, pollingApp); + telegramClients.put(BotConstants.SYSTEM_BOT_ID, telegramClient); log.info("Registered system bot for long polling"); } catch (Exception e) { log.error("Failed to register system bot: {}", e.getMessage()); + } finally { + getBotLock(BotConstants.SYSTEM_BOT_ID).unlock(); } } - public synchronized void unregisterBot(Long botId) { - TelegramBotsLongPollingApplication app = activeBots.remove(botId); - telegramClients.remove(botId); - if (app != null) { - try { - app.close(); - log.info("Unregistered bot {}", botId); - } catch (Exception e) { - log.error("Error closing bot {}: {}", botId, e.getMessage()); + public void unregisterBot(Long botId) { + getBotLock(botId).lock(); + try { + TelegramBotsLongPollingApplication app = activeBots.remove(botId); + telegramClients.remove(botId); + if (app != null) { + try { + app.close(); + log.info("Unregistered bot {}", botId); + } catch (Exception e) { + log.error("Error closing bot {}: {}", botId, e.getMessage()); + } } + } finally { + getBotLock(botId).unlock(); } } + @Override public TelegramClient getTelegramClient(Long botId) { TelegramClient client = telegramClients.get(botId); if (client == null && botId != null && botId > 0) { @@ -211,9 +241,10 @@ public TelegramClient getTelegramClient(Long botId) { Bot bot = botRepository.findById(botId).orElse(null); if (bot != null && bot.getTelegramToken() != null) { String token = encryptionUtil.decrypt(bot.getTelegramToken()); - if (token != null && !token.isBlank() && !"0000000000:dummyTokenPlaceholderForNoBotConfig".equals(token)) { - client = new OkHttpTelegramClient(token); - telegramClients.put(botId, client); + if (token != null && !token.isBlank() && !BotConstants.DUMMY_TOKEN_PLACEHOLDER.equals(token)) { + TelegramClient newClient = new OkHttpTelegramClient(token); + TelegramClient existing = telegramClients.putIfAbsent(botId, newClient); + client = existing != null ? existing : newClient; } } } catch (Exception e) { @@ -227,10 +258,13 @@ public TelegramClient getTelegramClient(Long botId) { void shutdown() { log.info("Shutting down {} active bots", activeBots.size()); activeBots.forEach((id, app) -> { + getBotLock(id).lock(); try { app.close(); } catch (Exception e) { log.error("Error shutting down bot {}: {}", id, e.getMessage()); + } finally { + getBotLock(id).unlock(); } }); activeBots.clear(); diff --git a/backend/src/main/java/com/launchly/bot/telegram/TelegramClientProvider.java b/backend/src/main/java/com/launchly/bot/telegram/TelegramClientProvider.java new file mode 100644 index 00000000..0e58114a --- /dev/null +++ b/backend/src/main/java/com/launchly/bot/telegram/TelegramClientProvider.java @@ -0,0 +1,7 @@ +package com.launchly.bot.telegram; + +import org.telegram.telegrambots.meta.generics.TelegramClient; + +public interface TelegramClientProvider { + TelegramClient getTelegramClient(Long botId); +} diff --git a/backend/src/main/java/com/launchly/bot/validator/BotAccessValidator.java b/backend/src/main/java/com/launchly/bot/validator/BotAccessValidator.java index 77a3d352..f2b8b6de 100644 --- a/backend/src/main/java/com/launchly/bot/validator/BotAccessValidator.java +++ b/backend/src/main/java/com/launchly/bot/validator/BotAccessValidator.java @@ -2,7 +2,9 @@ import com.launchly.bot.entity.Bot; import com.launchly.bot.entity.BotMember; +import com.launchly.bot.entity.WorkspaceRole; import com.launchly.bot.repository.BotMemberRepository; +import com.launchly.bot.repository.BotRepository; import com.launchly.common.exception.AppException; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -13,8 +15,18 @@ @RequiredArgsConstructor public class BotAccessValidator { + private final BotRepository botRepository; private final BotMemberRepository botMemberRepository; + public Bot getBotWithAccess(Long botId, Long userId) { + Bot bot = botRepository.findById(botId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.not_found")); + if (!bot.getUser().getId().equals(userId) && !botMemberRepository.existsByBotOwnerIdAndUserId(bot.getUser().getId(), userId)) { + throw new AppException(HttpStatus.FORBIDDEN, "bot.error.access_denied"); + } + return bot; + } + public void validateWriteAccess(Bot bot, Long userId) { if (bot == null || userId == null) { throw new AppException(HttpStatus.FORBIDDEN, "bot.error.access_denied"); @@ -23,7 +35,7 @@ public void validateWriteAccess(Bot bot, Long userId) { BotMember member = getWorkspaceMembership(bot, userId) .orElseThrow(() -> new AppException(HttpStatus.FORBIDDEN, "bot.error.access_denied")); - if ("Viewer".equalsIgnoreCase(member.getRole())) { + if (WorkspaceRole.VIEWER.getValue().equalsIgnoreCase(member.getRole())) { throw new AppException(HttpStatus.FORBIDDEN, "bot.error.viewer_cannot_modify"); } } diff --git a/backend/src/main/java/com/launchly/broadcast/controller/BroadcastController.java b/backend/src/main/java/com/launchly/broadcast/controller/BroadcastController.java index 5289a687..241b2a41 100644 --- a/backend/src/main/java/com/launchly/broadcast/controller/BroadcastController.java +++ b/backend/src/main/java/com/launchly/broadcast/controller/BroadcastController.java @@ -63,6 +63,21 @@ public ResponseEntity createTag( .body(tagService.createTag(botId, userDetails.getId(), request)); } + @Operation(summary = "Update tag", description = "Rename an existing tag.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Tag updated successfully"), + @ApiResponse(responseCode = "400", description = "Validation error / duplicate tag", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Tag not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PutMapping("/tags/{tagId}") + public ResponseEntity updateTag( + @Parameter(description = "Target Bot ID") @PathVariable Long botId, + @Parameter(description = "Tag ID") @PathVariable Long tagId, + @Valid @RequestBody CreateTagRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(tagService.updateTag(tagId, userDetails.getId(), request)); + } + @Operation(summary = "Delete tag", description = "Remove a tag and untag subscribers.") @ApiResponses({ @ApiResponse(responseCode = "204", description = "Tag deleted successfully"), diff --git a/backend/src/main/java/com/launchly/broadcast/entity/BotUserTag.java b/backend/src/main/java/com/launchly/broadcast/entity/BotUserTag.java index 686a59a1..fc0884f7 100644 --- a/backend/src/main/java/com/launchly/broadcast/entity/BotUserTag.java +++ b/backend/src/main/java/com/launchly/broadcast/entity/BotUserTag.java @@ -11,7 +11,9 @@ import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -22,8 +24,10 @@ @Index(name = "idx_bot_user_tags_bot_user_id", columnList = "bot_user_id"), @Index(name = "idx_bot_user_tags_tag_id", columnList = "tag_id") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"botUser", "tag"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/broadcast/entity/BroadcastCampaign.java b/backend/src/main/java/com/launchly/broadcast/entity/BroadcastCampaign.java index 25334bcd..6016f48e 100644 --- a/backend/src/main/java/com/launchly/broadcast/entity/BroadcastCampaign.java +++ b/backend/src/main/java/com/launchly/broadcast/entity/BroadcastCampaign.java @@ -14,9 +14,11 @@ import jakarta.persistence.Version; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; import java.time.LocalDateTime; @@ -28,8 +30,10 @@ @Index(name = "idx_broadcast_scheduled", columnList = "status, scheduled_at"), @Index(name = "idx_broadcast_bot_status_created", columnList = "bot_id, status, created_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/broadcast/entity/Tag.java b/backend/src/main/java/com/launchly/broadcast/entity/Tag.java index 6c8f04cf..02d57534 100644 --- a/backend/src/main/java/com/launchly/broadcast/entity/Tag.java +++ b/backend/src/main/java/com/launchly/broadcast/entity/Tag.java @@ -12,7 +12,9 @@ import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -23,8 +25,10 @@ @Index(name = "idx_tags_bot_id", columnList = "bot_id"), @Index(name = "idx_tags_bot_created", columnList = "bot_id, created_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/broadcast/repository/BotUserTagRepository.java b/backend/src/main/java/com/launchly/broadcast/repository/BotUserTagRepository.java index 182f8d0b..1bd6dee6 100644 --- a/backend/src/main/java/com/launchly/broadcast/repository/BotUserTagRepository.java +++ b/backend/src/main/java/com/launchly/broadcast/repository/BotUserTagRepository.java @@ -12,6 +12,9 @@ public interface BotUserTagRepository extends JpaRepository { @EntityGraph(attributePaths = {"tag", "botUser"}) List findByBotUserId(Long botUserId); + @EntityGraph(attributePaths = {"tag", "botUser"}) + List findByBotUserIdIn(List botUserIds); + boolean existsByBotUserIdAndTagId(Long botUserId, Long tagId); @Query("SELECT but.botUser.id FROM BotUserTag but WHERE but.tag.name = :tagName AND but.tag.bot.id = :botId") diff --git a/backend/src/main/java/com/launchly/broadcast/repository/BroadcastCampaignRepository.java b/backend/src/main/java/com/launchly/broadcast/repository/BroadcastCampaignRepository.java index c7a79732..9a36fa24 100644 --- a/backend/src/main/java/com/launchly/broadcast/repository/BroadcastCampaignRepository.java +++ b/backend/src/main/java/com/launchly/broadcast/repository/BroadcastCampaignRepository.java @@ -26,7 +26,15 @@ public interface BroadcastCampaignRepository extends JpaRepository findById(Long id); @Query("SELECT COUNT(c) FROM BroadcastCampaign c WHERE c.bot.user.id = :userId") - long countByUserId(@Param("userId") Long userId); + long countByUserId(Long userId); + + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + @Query("SELECT COALESCE(SUM(c.sentCount), 0L) FROM BroadcastCampaign c WHERE c.createdAt >= :start AND c.createdAt <= :end") + long sumSentCountByCreatedAtBetween(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + @EntityGraph(attributePaths = {"bot"}) + List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); @EntityGraph(attributePaths = {"bot"}) List findByStatusAndScheduledAtBefore(CampaignStatus status, LocalDateTime dateTime); diff --git a/backend/src/main/java/com/launchly/broadcast/scheduler/BroadcastScheduler.java b/backend/src/main/java/com/launchly/broadcast/scheduler/BroadcastScheduler.java index 6abfed23..5f6f8563 100644 --- a/backend/src/main/java/com/launchly/broadcast/scheduler/BroadcastScheduler.java +++ b/backend/src/main/java/com/launchly/broadcast/scheduler/BroadcastScheduler.java @@ -1,9 +1,8 @@ package com.launchly.broadcast.scheduler; import com.launchly.broadcast.entity.BroadcastCampaign; -import com.launchly.broadcast.entity.CampaignStatus; import com.launchly.broadcast.repository.BroadcastCampaignRepository; -import com.launchly.broadcast.service.BroadcastService; +import com.launchly.broadcast.service.BroadcastExecutionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -16,10 +15,12 @@ @Slf4j public class BroadcastScheduler { + private static final long SCHEDULED_CAMPAIGN_CHECK_DELAY_MS = 30_000L; + private final BroadcastCampaignRepository campaignRepository; - private final BroadcastService broadcastService; + private final BroadcastExecutionService broadcastExecutionService; - @Scheduled(fixedDelay = 30000) + @Scheduled(fixedDelay = SCHEDULED_CAMPAIGN_CHECK_DELAY_MS) public void processScheduledCampaigns() { List dueCampaigns = campaignRepository .findScheduledCampaigns(LocalDateTime.now()); @@ -32,7 +33,7 @@ public void processScheduledCampaigns() { for (BroadcastCampaign campaign : dueCampaigns) { try { - broadcastService.sendCampaign(campaign.getId()); + broadcastExecutionService.sendCampaign(campaign.getId()); log.info("Dispatched scheduled campaign {} ('{}')", campaign.getId(), campaign.getName()); } catch (Exception e) { log.error("Failed to dispatch scheduled campaign {}: {}", diff --git a/backend/src/main/java/com/launchly/broadcast/service/BroadcastExecutionService.java b/backend/src/main/java/com/launchly/broadcast/service/BroadcastExecutionService.java new file mode 100644 index 00000000..7767733c --- /dev/null +++ b/backend/src/main/java/com/launchly/broadcast/service/BroadcastExecutionService.java @@ -0,0 +1,5 @@ +package com.launchly.broadcast.service; + +public interface BroadcastExecutionService { + void sendCampaign(Long campaignId); +} diff --git a/backend/src/main/java/com/launchly/broadcast/service/BroadcastService.java b/backend/src/main/java/com/launchly/broadcast/service/BroadcastService.java index af19c197..decc8113 100644 --- a/backend/src/main/java/com/launchly/broadcast/service/BroadcastService.java +++ b/backend/src/main/java/com/launchly/broadcast/service/BroadcastService.java @@ -12,11 +12,13 @@ public interface BroadcastService { List getCampaigns(Long botId, Long userId); - void sendCampaign(Long campaignId); - CampaignResponse sendNow(Long campaignId, Long userId); CampaignResponse cancelSchedule(Long campaignId, Long userId); void deleteCampaign(Long campaignId, Long userId); + + CampaignResponse getCampaign(Long campaignId); + + boolean isCampaignBlocked(Long campaignId); } diff --git a/backend/src/main/java/com/launchly/broadcast/service/TagService.java b/backend/src/main/java/com/launchly/broadcast/service/TagService.java index 9a10649a..583bf77b 100644 --- a/backend/src/main/java/com/launchly/broadcast/service/TagService.java +++ b/backend/src/main/java/com/launchly/broadcast/service/TagService.java @@ -15,5 +15,7 @@ public interface TagService { TagResponse createTag(Long botId, Long userId, CreateTagRequest request); + TagResponse updateTag(Long tagId, Long userId, CreateTagRequest request); + void deleteTag(Long tagId, Long userId); } diff --git a/backend/src/main/java/com/launchly/broadcast/service/impl/BroadcastExecutionServiceImpl.java b/backend/src/main/java/com/launchly/broadcast/service/impl/BroadcastExecutionServiceImpl.java new file mode 100644 index 00000000..2f8118f0 --- /dev/null +++ b/backend/src/main/java/com/launchly/broadcast/service/impl/BroadcastExecutionServiceImpl.java @@ -0,0 +1,196 @@ +package com.launchly.broadcast.service.impl; + +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.service.FlowEngineService; +import com.launchly.bot.service.TelegramSendService; +import com.launchly.broadcast.entity.BroadcastCampaign; +import com.launchly.broadcast.entity.CampaignStatus; +import com.launchly.broadcast.repository.BroadcastCampaignRepository; +import com.launchly.broadcast.service.BroadcastExecutionService; +import com.launchly.broadcast.service.BroadcastFilterService; +import com.launchly.common.exception.AppException; +import com.launchly.common.utils.SanitizationUtil; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +@Service +@Slf4j +public class BroadcastExecutionServiceImpl implements BroadcastExecutionService { + + private static final int BATCH_SIZE = 25; + private static final long BATCH_DELAY_MS = 1000; + private static final Duration LOCK_TTL = Duration.ofMinutes(10); + + private final StringRedisTemplate stringRedisTemplate; + private final BroadcastCampaignRepository campaignRepository; + private final BotRepository botRepository; + private final BroadcastFilterService broadcastFilterService; + private final ObjectMapper objectMapper; + private final FlowEngineService flowEngineService; + private final TelegramSendService telegramSendService; + + public BroadcastExecutionServiceImpl(StringRedisTemplate stringRedisTemplate, + BroadcastCampaignRepository campaignRepository, + BotRepository botRepository, + BroadcastFilterService broadcastFilterService, + ObjectMapper objectMapper, + @Lazy FlowEngineService flowEngineService, + TelegramSendService telegramSendService) { + this.stringRedisTemplate = stringRedisTemplate; + this.campaignRepository = campaignRepository; + this.botRepository = botRepository; + this.broadcastFilterService = broadcastFilterService; + this.objectMapper = objectMapper; + this.flowEngineService = flowEngineService; + this.telegramSendService = telegramSendService; + } + + @Override + @Async("broadcastExecutor") + public void sendCampaign(Long campaignId) { + String lockKey = "lock:broadcast:send:" + campaignId; + Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_TTL); + if (Boolean.FALSE.equals(acquired)) { + log.warn("Broadcast campaign {} is already being dispatched by another process", campaignId); + return; + } + + BroadcastCampaign campaign = null; + try { + campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "broadcast.error.not_found")); + + if (campaign.isBlocked() || campaign.getStatus() == CampaignStatus.BLOCKED) { + log.warn("Campaign {} is BLOCKED by administrator — skipping execution", campaignId); + return; + } + + List targetUsers = new ArrayList<>(); + if (Boolean.TRUE.equals(campaign.getTargetAllBots())) { + Long ownerId = campaign.getBot().getUser().getId(); + List userBots = botRepository.findAllAccessibleByUserId(ownerId); + for (Bot b : userBots) { + targetUsers.addAll(broadcastFilterService.filterUsers( + b.getId(), campaign.getFilterType(), campaign.getFilterValue() + )); + } + } else { + Long botId = campaign.getBot().getId(); + targetUsers.addAll(broadcastFilterService.filterUsers( + botId, campaign.getFilterType(), campaign.getFilterValue() + )); + } + + int previousSent = campaign.getSentCount(); + int previousFailed = campaign.getFailedCount(); + int previousTotal = campaign.getTotalCount(); + + campaign.setStatus(CampaignStatus.IN_PROGRESS); + campaign.setTotalCount(previousTotal + targetUsers.size()); + campaign = campaignRepository.save(campaign); + + log.info("Starting broadcast campaign {} to {} users", campaignId, targetUsers.size()); + String firstConnectedNodeId = null; + try { + String nodesJson = campaign.getNodes(); + String edgesJson = campaign.getEdges(); + if (nodesJson != null && !nodesJson.trim().isEmpty() && !"[]".equals(nodesJson)) { + JsonNode nodesNode = objectMapper.readTree(nodesJson); + JsonNode edgesNode = edgesJson != null && !edgesJson.trim().isEmpty() ? objectMapper.readTree(edgesJson) : objectMapper.createArrayNode(); + + String startNodeId = null; + for (JsonNode n : nodesNode) { + if ("START_BROADCAST".equals(n.get("type").asText())) { + startNodeId = n.get("id").asText(); + break; + } + } + + if (startNodeId != null) { + for (JsonNode e : edgesNode) { + if (startNodeId.equals(e.get("source").asText())) { + firstConnectedNodeId = e.get("target").asText(); + break; + } + } + } + } + } catch (Exception e) { + log.error("Failed to parse campaign flow for dispatching: {}", e.getMessage()); + } + + final String connectedNodeId = firstConnectedNodeId; + + int sent = 0; + int failed = 0; + + for (int i = 0; i < targetUsers.size(); i++) { + BotUser user = targetUsers.get(i); + Long userBotId = user.getBot().getId(); + try { + if (connectedNodeId != null) { + flowEngineService.runFlow(userBotId, user, connectedNodeId, campaignId); + } else if (campaign.getMessage() != null && !campaign.getMessage().trim().isEmpty()) { + String sanitizedText = SanitizationUtil.sanitizeForTelegram(campaign.getMessage()); + telegramSendService.sendMessage(userBotId, user.getTelegramId(), sanitizedText); + } + sent++; + } catch (Exception e) { + failed++; + log.error("Failed to execute broadcast for telegramId={} on botId={}: {}", + user.getTelegramId(), userBotId, e.getMessage()); + } + + if ((i + 1) % BATCH_SIZE == 0 && i + 1 < targetUsers.size()) { + try { + Thread.sleep(BATCH_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Broadcast campaign {} interrupted", campaignId); + break; + } + } + } + + BroadcastCampaign freshCampaign = campaignRepository.findById(campaignId).orElse(campaign); + if (freshCampaign != null) { + int totalSent = previousSent + sent; + int totalFailed = previousFailed + failed; + int totalCount = previousTotal + targetUsers.size(); + freshCampaign.setSentCount(totalSent); + freshCampaign.setFailedCount(totalFailed); + freshCampaign.setTotalCount(totalCount); + freshCampaign.setStatus(totalFailed == totalCount && totalCount > 0 + ? CampaignStatus.FAILED + : CampaignStatus.COMPLETED); + campaignRepository.save(freshCampaign); + + log.info("Broadcast campaign {} completed: sent={}, failed={}, total={}", + campaignId, totalSent, totalFailed, totalCount); + } + } catch (Exception fatalEx) { + log.error("Fatal error during broadcast campaign {} execution: {}", campaignId, fatalEx.getMessage(), fatalEx); + try { + campaignRepository.findById(campaignId).ifPresent(c -> { + c.setStatus(CampaignStatus.FAILED); + campaignRepository.save(c); + }); + } catch (Exception ex) { + log.error("Failed to set campaign {} to FAILED: {}", campaignId, ex.getMessage()); + } + } finally { + stringRedisTemplate.delete(lockKey); + } + } +} diff --git a/backend/src/main/java/com/launchly/broadcast/service/impl/BroadcastServiceImpl.java b/backend/src/main/java/com/launchly/broadcast/service/impl/BroadcastServiceImpl.java index 7edac224..734eb300 100644 --- a/backend/src/main/java/com/launchly/broadcast/service/impl/BroadcastServiceImpl.java +++ b/backend/src/main/java/com/launchly/broadcast/service/impl/BroadcastServiceImpl.java @@ -1,40 +1,29 @@ package com.launchly.broadcast.service.impl; -import com.launchly.auth.entity.User; import com.launchly.bot.entity.Bot; -import com.launchly.bot.entity.BotUser; import com.launchly.bot.repository.BotRepository; -import com.launchly.bot.service.FlowEngineService; -import com.launchly.bot.service.TelegramSendService; import com.launchly.broadcast.dto.request.CreateCampaignRequest; import com.launchly.broadcast.dto.response.CampaignResponse; import com.launchly.broadcast.entity.BroadcastCampaign; import com.launchly.broadcast.entity.CampaignStatus; import com.launchly.broadcast.mapper.BroadcastMapper; import com.launchly.broadcast.repository.BroadcastCampaignRepository; +import com.launchly.broadcast.service.BroadcastExecutionService; import com.launchly.broadcast.service.BroadcastFilterService; import com.launchly.broadcast.service.BroadcastService; import com.launchly.broadcast.util.BroadcastUtils; import com.launchly.broadcast.validator.BroadcastValidator; import com.launchly.billing.service.PlanLimitService; import com.launchly.common.exception.AppException; -import com.launchly.common.utils.SanitizationUtil; -import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; -import org.springframework.context.annotation.Lazy; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.ArrayList; import java.util.stream.Collectors; -import com.launchly.bot.repository.BotMemberRepository; -import com.launchly.bot.entity.BotMember; import com.launchly.admin.service.UserAuditService; import org.springframework.data.redis.core.StringRedisTemplate; -import java.time.Duration; import java.time.LocalDateTime; @Service @@ -45,41 +34,35 @@ public class BroadcastServiceImpl implements BroadcastService { private static final long BATCH_DELAY_MS = 1000; private final BroadcastCampaignRepository campaignRepository; private final BroadcastFilterService broadcastFilterService; - private final TelegramSendService telegramSendService; private final BroadcastMapper broadcastMapper; private final PlanLimitService planLimitService; private final ObjectMapper objectMapper; - private final FlowEngineService flowEngineService; private final UserAuditService userAuditService; private final BroadcastValidator broadcastValidator; + private final BroadcastExecutionService broadcastExecutionService; private final BotRepository botRepository; - private final BotMemberRepository botMemberRepository; private final StringRedisTemplate stringRedisTemplate; public BroadcastServiceImpl(BroadcastCampaignRepository campaignRepository, BroadcastFilterService broadcastFilterService, - TelegramSendService telegramSendService, BroadcastMapper broadcastMapper, PlanLimitService planLimitService, ObjectMapper objectMapper, - @Lazy FlowEngineService flowEngineService, UserAuditService userAuditService, BroadcastValidator broadcastValidator, BotRepository botRepository, - BotMemberRepository botMemberRepository, - StringRedisTemplate stringRedisTemplate) { + StringRedisTemplate stringRedisTemplate, + BroadcastExecutionService broadcastExecutionService) { this.campaignRepository = campaignRepository; this.broadcastFilterService = broadcastFilterService; - this.telegramSendService = telegramSendService; this.broadcastMapper = broadcastMapper; this.planLimitService = planLimitService; this.objectMapper = objectMapper; - this.flowEngineService = flowEngineService; this.userAuditService = userAuditService; this.broadcastValidator = broadcastValidator; this.botRepository = botRepository; - this.botMemberRepository = botMemberRepository; this.stringRedisTemplate = stringRedisTemplate; + this.broadcastExecutionService = broadcastExecutionService; } @Override @@ -174,155 +157,6 @@ public List getCampaigns(Long botId, Long userId) { return list.stream().map(this::toResponse).collect(Collectors.toList()); } - @Override - @Async("broadcastExecutor") - public void sendCampaign(Long campaignId) { - String lockKey = "lock:broadcast:send:" + campaignId; - Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); - if (Boolean.FALSE.equals(acquired)) { - log.warn("Broadcast campaign {} is already being dispatched by another process", campaignId); - return; - } - - BroadcastCampaign campaign = null; - try { - campaign = campaignRepository.findById(campaignId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Campaign not found")); - - if (campaign.isBlocked() || campaign.getStatus() == CampaignStatus.BLOCKED) { - log.warn("Campaign {} is BLOCKED by administrator — skipping execution", campaignId); - return; - } - - List targetUsers = new ArrayList<>(); - if (Boolean.TRUE.equals(campaign.getTargetAllBots())) { - Long ownerId = campaign.getBot().getUser().getId(); - List userBots = new ArrayList<>(botRepository.findAllByUserId(ownerId)); - List memberships = botMemberRepository.findByUserId(ownerId); - for (BotMember bm : memberships) { - User owner = bm.getBot().getUser(); - List ownerBots = botRepository.findAllByUserId(owner.getId()); - for (Bot b : ownerBots) { - if (userBots.stream().noneMatch(existing -> existing.getId().equals(b.getId()))) { - userBots.add(b); - } - } - } - for (Bot b : userBots) { - targetUsers.addAll(broadcastFilterService.filterUsers( - b.getId(), campaign.getFilterType(), campaign.getFilterValue() - )); - } - } else { - Long botId = campaign.getBot().getId(); - targetUsers.addAll(broadcastFilterService.filterUsers( - botId, campaign.getFilterType(), campaign.getFilterValue() - )); - } - - int previousSent = campaign.getSentCount(); - int previousFailed = campaign.getFailedCount(); - int previousTotal = campaign.getTotalCount(); - - campaign.setStatus(CampaignStatus.IN_PROGRESS); - campaign.setTotalCount(previousTotal + targetUsers.size()); - campaign = campaignRepository.save(campaign); - - log.info("Starting broadcast campaign {} to {} users", campaignId, targetUsers.size()); - String firstConnectedNodeId = null; - try { - String nodesJson = campaign.getNodes(); - String edgesJson = campaign.getEdges(); - if (nodesJson != null && !nodesJson.trim().isEmpty() && !"[]".equals(nodesJson)) { - JsonNode nodesNode = objectMapper.readTree(nodesJson); - JsonNode edgesNode = edgesJson != null && !edgesJson.trim().isEmpty() ? objectMapper.readTree(edgesJson) : objectMapper.createArrayNode(); - - String startNodeId = null; - for (JsonNode n : nodesNode) { - if ("START_BROADCAST".equals(n.get("type").asText())) { - startNodeId = n.get("id").asText(); - break; - } - } - - if (startNodeId != null) { - for (JsonNode e : edgesNode) { - if (startNodeId.equals(e.get("source").asText())) { - firstConnectedNodeId = e.get("target").asText(); - break; - } - } - } - } - } catch (Exception e) { - log.error("Failed to parse campaign flow for dispatching: {}", e.getMessage()); - } - - final String connectedNodeId = firstConnectedNodeId; - - int sent = 0; - int failed = 0; - - for (int i = 0; i < targetUsers.size(); i++) { - BotUser user = targetUsers.get(i); - Long userBotId = user.getBot().getId(); - try { - if (connectedNodeId != null) { - flowEngineService.runFlow(userBotId, user, connectedNodeId, campaignId); - } else if (campaign.getMessage() != null && !campaign.getMessage().trim().isEmpty()) { - String sanitizedText = SanitizationUtil.sanitizeForTelegram(campaign.getMessage()); - telegramSendService.sendMessage(userBotId, user.getTelegramId(), sanitizedText); - } - sent++; - } catch (Exception e) { - failed++; - log.error("Failed to execute broadcast for telegramId={} on botId={}: {}", - user.getTelegramId(), userBotId, e.getMessage()); - } - - if ((i + 1) % BATCH_SIZE == 0 && i + 1 < targetUsers.size()) { - try { - Thread.sleep(BATCH_DELAY_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Broadcast campaign {} interrupted", campaignId); - break; - } - } - } - - BroadcastCampaign freshCampaign = campaignRepository.findById(campaignId).orElse(campaign); - if (freshCampaign != null) { - int totalSent = previousSent + sent; - int totalFailed = previousFailed + failed; - int totalCount = previousTotal + targetUsers.size(); - freshCampaign.setSentCount(totalSent); - freshCampaign.setFailedCount(totalFailed); - freshCampaign.setTotalCount(totalCount); - freshCampaign.setStatus(totalFailed == totalCount && totalCount > 0 - ? CampaignStatus.FAILED - : CampaignStatus.COMPLETED); - campaignRepository.save(freshCampaign); - - log.info("Broadcast campaign {} completed: sent={}, failed={}, total={}", - campaignId, totalSent, totalFailed, totalCount); - } - } catch (Exception fatalEx) { - log.error("Fatal error during broadcast campaign {} execution: {}", campaignId, fatalEx.getMessage(), fatalEx); - try { - campaignRepository.findById(campaignId).ifPresent(c -> { - c.setStatus(CampaignStatus.FAILED); - campaignRepository.save(c); - }); - } catch (Exception ex) { - log.error("Failed to set campaign {} to FAILED: {}", campaignId, ex.getMessage()); - } - } finally { - stringRedisTemplate.delete(lockKey); - } - } - - @Override @Transactional public CampaignResponse sendNow(Long campaignId, Long userId) { @@ -345,7 +179,7 @@ public CampaignResponse sendNow(Long campaignId, Long userId) { userAuditService.logBroadcastLaunched(campaign.getBot().getUser(), campaign.getId(), campaign.getName(), "FINISHED", LocalDateTime.now()); CampaignResponse response = toResponse(campaign); - sendCampaign(campaignId); + broadcastExecutionService.sendCampaign(campaignId); return response; } @@ -377,7 +211,7 @@ public CampaignResponse cancelSchedule(Long campaignId, Long userId) { @Transactional public void deleteCampaign(Long campaignId, Long userId) { BroadcastCampaign campaign = campaignRepository.findById(campaignId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Campaign not found")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "broadcast.error.not_found")); broadcastValidator.validateWriteAccess(campaign.getBot().getId(), userId); @@ -409,5 +243,20 @@ private CampaignResponse toResponse(BroadcastCampaign campaign) { campaign.getUpdatedAt() ); } -} + @Override + @Transactional(readOnly = true) + public CampaignResponse getCampaign(Long campaignId) { + BroadcastCampaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "broadcast.error.not_found")); + return toResponse(campaign); + } + + @Override + @Transactional(readOnly = true) + public boolean isCampaignBlocked(Long campaignId) { + return campaignRepository.findById(campaignId) + .map(c -> c.isBlocked() || c.getStatus() == CampaignStatus.BLOCKED) + .orElse(false); + } +} diff --git a/backend/src/main/java/com/launchly/broadcast/service/impl/TagServiceImpl.java b/backend/src/main/java/com/launchly/broadcast/service/impl/TagServiceImpl.java index 97d24768..8af2885a 100644 --- a/backend/src/main/java/com/launchly/broadcast/service/impl/TagServiceImpl.java +++ b/backend/src/main/java/com/launchly/broadcast/service/impl/TagServiceImpl.java @@ -14,6 +14,8 @@ import com.launchly.common.exception.AppException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import com.launchly.common.constant.CacheConstants; +import org.springframework.cache.Cache; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.CacheManager; import org.springframework.http.HttpStatus; @@ -94,6 +96,28 @@ public TagResponse createTag(Long botId, Long userId, CreateTagRequest request) return broadcastMapper.toTagResponse(tag); } + @Override + @Transactional + public TagResponse updateTag(Long tagId, Long userId, CreateTagRequest request) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "broadcast.error.tag_not_found")); + Long botId = tag.getBot().getId(); + validateBotOwnership(botId, userId); + + String trimmedName = request.name().trim(); + tagRepository.findByBotIdAndName(botId, trimmedName) + .filter(existing -> !existing.getId().equals(tagId)) + .ifPresent(existing -> { + throw new AppException(HttpStatus.CONFLICT, "broadcast.error.tag_already_exists"); + }); + + tag.setName(trimmedName); + tag = tagRepository.save(tag); + evictTagsCache(botId); + log.info("Updated tag {} (newName='{}')", tagId, trimmedName); + return broadcastMapper.toTagResponse(tag); + } + @Override @Transactional public void deleteTag(Long tagId, Long userId) { @@ -115,7 +139,7 @@ private Bot validateBotOwnership(Long botId, Long userId) { private void evictTagsCache(Long botId) { if (botId != null) { - org.springframework.cache.Cache cache = cacheManager.getCache("tags"); + Cache cache = cacheManager.getCache(CacheConstants.TAGS); if (cache != null) { cache.evict(botId); } diff --git a/backend/src/main/java/com/launchly/broadcast/validator/BroadcastValidator.java b/backend/src/main/java/com/launchly/broadcast/validator/BroadcastValidator.java index 5d3cebd8..6bbc35d5 100644 --- a/backend/src/main/java/com/launchly/broadcast/validator/BroadcastValidator.java +++ b/backend/src/main/java/com/launchly/broadcast/validator/BroadcastValidator.java @@ -2,6 +2,7 @@ import com.launchly.bot.entity.Bot; import com.launchly.bot.entity.BotMember; +import com.launchly.bot.entity.WorkspaceRole; import com.launchly.bot.repository.BotMemberRepository; import com.launchly.bot.repository.BotRepository; import com.launchly.common.exception.AppException; @@ -34,7 +35,7 @@ public void validateWriteAccess(Long botId, Long userId) { if (!bot.getUser().getId().equals(userId)) { BotMember member = botMemberRepository.findWorkspaceMemberships(botId, userId).stream().findFirst() .orElseThrow(() -> new AppException(HttpStatus.FORBIDDEN, "bot.error.access_denied")); - if ("Viewer".equalsIgnoreCase(member.getRole())) { + if (WorkspaceRole.VIEWER.getValue().equalsIgnoreCase(member.getRole())) { throw new AppException(HttpStatus.FORBIDDEN, "broadcast.error.viewer_cannot_modify"); } } diff --git a/backend/src/main/java/com/launchly/common/config/AsyncConfig.java b/backend/src/main/java/com/launchly/common/config/AsyncConfig.java index c8d5abdf..07a9fa4f 100644 --- a/backend/src/main/java/com/launchly/common/config/AsyncConfig.java +++ b/backend/src/main/java/com/launchly/common/config/AsyncConfig.java @@ -2,11 +2,15 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; @Configuration @EnableAsync @@ -14,24 +18,35 @@ public class AsyncConfig { @Bean(name = "taskExecutor") - public Executor taskExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); - executor.setMaxPoolSize(20); - executor.setQueueCapacity(100); - executor.setThreadNamePrefix("async-task-"); - executor.initialize(); + public AsyncTaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + executor.setVirtualThreads(true); + executor.setThreadNamePrefix("async-vt-"); return executor; } @Bean(name = "broadcastExecutor") - public Executor broadcastExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(2); - executor.setMaxPoolSize(5); - executor.setQueueCapacity(10); - executor.setThreadNamePrefix("broadcast-"); - executor.initialize(); + public AsyncTaskExecutor broadcastExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + executor.setVirtualThreads(true); + executor.setThreadNamePrefix("broadcast-vt-"); return executor; } + + @Bean(name = "taskScheduler") + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(5); + scheduler.setThreadNamePrefix("scheduled-task-"); + scheduler.initialize(); + return scheduler; + } + + @Bean(name = "scheduledExecutorService") + public ScheduledExecutorService scheduledExecutorService(TaskScheduler taskScheduler) { + if (taskScheduler instanceof ThreadPoolTaskScheduler threadPoolTaskScheduler) { + return threadPoolTaskScheduler.getScheduledExecutor(); + } + return Executors.newScheduledThreadPool(5); + } } diff --git a/backend/src/main/java/com/launchly/common/config/CacheConfig.java b/backend/src/main/java/com/launchly/common/config/CacheConfig.java index 1371d6cc..25dab127 100644 --- a/backend/src/main/java/com/launchly/common/config/CacheConfig.java +++ b/backend/src/main/java/com/launchly/common/config/CacheConfig.java @@ -18,11 +18,23 @@ @Profile("!test") public class CacheConfig { + private static final Duration DEFAULT_TTL = Duration.ofMinutes(10); + private static final Duration TTL_PLANS = Duration.ofHours(24); + private static final Duration TTL_FLOW_SCHEMAS = Duration.ofHours(1); + private static final Duration TTL_ADMIN_STATS = Duration.ofMinutes(5); + private static final Duration TTL_SUBSCRIPTION = Duration.ofMinutes(30); + private static final Duration TTL_BOTS = Duration.ofMinutes(10); + private static final Duration TTL_TAGS = Duration.ofMinutes(10); + private static final Duration TTL_BLOG_ARTICLES = Duration.ofHours(1); + private static final Duration TTL_BLOG_ARTICLE = Duration.ofHours(2); + private static final Duration TTL_TEMPLATES = Duration.ofHours(2); + private static final Duration TTL_I18N = Duration.ofHours(24); + @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration .defaultCacheConfig() - .entryTtl(Duration.ofMinutes(10)) + .entryTtl(DEFAULT_TTL) .serializeKeysWith( RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) @@ -31,17 +43,17 @@ public RedisCacheManager cacheManager(RedisConnectionFactory factory) { .fromSerializer(GenericJacksonJsonRedisSerializer.builder().enableUnsafeDefaultTyping().build())); Map configs = Map.ofEntries( - Map.entry("plans", defaultConfig.entryTtl(Duration.ofHours(24))), - Map.entry("plan", defaultConfig.entryTtl(Duration.ofHours(24))), - Map.entry("flow_schemas", defaultConfig.entryTtl(Duration.ofHours(1))), - Map.entry("admin_stats", defaultConfig.entryTtl(Duration.ofMinutes(5))), - Map.entry("subscription", defaultConfig.entryTtl(Duration.ofMinutes(30))), - Map.entry("bots", defaultConfig.entryTtl(Duration.ofMinutes(10))), - Map.entry("tags", defaultConfig.entryTtl(Duration.ofMinutes(10))), - Map.entry("blog_articles", defaultConfig.entryTtl(Duration.ofHours(1))), - Map.entry("blog_article", defaultConfig.entryTtl(Duration.ofHours(2))), - Map.entry("templates", defaultConfig.entryTtl(Duration.ofHours(2))), - Map.entry("i18n", defaultConfig.entryTtl(Duration.ofHours(24))) + Map.entry("plans", defaultConfig.entryTtl(TTL_PLANS)), + Map.entry("plan", defaultConfig.entryTtl(TTL_PLANS)), + Map.entry("flow_schemas", defaultConfig.entryTtl(TTL_FLOW_SCHEMAS)), + Map.entry("admin_stats", defaultConfig.entryTtl(TTL_ADMIN_STATS)), + Map.entry("subscription", defaultConfig.entryTtl(TTL_SUBSCRIPTION)), + Map.entry("bots", defaultConfig.entryTtl(TTL_BOTS)), + Map.entry("tags", defaultConfig.entryTtl(TTL_TAGS)), + Map.entry("blog_articles", defaultConfig.entryTtl(TTL_BLOG_ARTICLES)), + Map.entry("blog_article", defaultConfig.entryTtl(TTL_BLOG_ARTICLE)), + Map.entry("templates", defaultConfig.entryTtl(TTL_TEMPLATES)), + Map.entry("i18n", defaultConfig.entryTtl(TTL_I18N)) ); return RedisCacheManager.builder(factory) diff --git a/backend/src/main/java/com/launchly/common/config/HttpClientConfig.java b/backend/src/main/java/com/launchly/common/config/HttpClientConfig.java index c107c499..1faed09d 100644 --- a/backend/src/main/java/com/launchly/common/config/HttpClientConfig.java +++ b/backend/src/main/java/com/launchly/common/config/HttpClientConfig.java @@ -2,16 +2,31 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + import java.net.http.HttpClient; import java.time.Duration; @Configuration public class HttpClientConfig { + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration REST_CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration REST_READ_TIMEOUT = Duration.ofSeconds(10); + @Bean public HttpClient httpClient() { return HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) + .connectTimeout(DEFAULT_CONNECT_TIMEOUT) .build(); } + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(REST_CONNECT_TIMEOUT); + factory.setReadTimeout(REST_READ_TIMEOUT); + return new RestTemplate(factory); + } } diff --git a/backend/src/main/java/com/launchly/common/config/JacksonConfig.java b/backend/src/main/java/com/launchly/common/config/JacksonConfig.java index b07ddc4c..3eaf2ee7 100644 --- a/backend/src/main/java/com/launchly/common/config/JacksonConfig.java +++ b/backend/src/main/java/com/launchly/common/config/JacksonConfig.java @@ -1,6 +1,9 @@ package com.launchly.common.config; -import tools.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.databind.DeserializationFeature; import tools.jackson.databind.cfg.DateTimeFeature; import tools.jackson.databind.json.JsonMapper; import org.springframework.context.annotation.Bean; @@ -14,8 +17,17 @@ public class JacksonConfig { @Primary public JsonMapper objectMapper() { return JsonMapper.builder() - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(tools.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); } + + @Bean + public ObjectMapper legacyObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + return mapper; + } } diff --git a/backend/src/main/java/com/launchly/common/config/RateLimitConfig.java b/backend/src/main/java/com/launchly/common/config/RateLimitConfig.java index ff1db76b..84ed31a0 100644 --- a/backend/src/main/java/com/launchly/common/config/RateLimitConfig.java +++ b/backend/src/main/java/com/launchly/common/config/RateLimitConfig.java @@ -18,6 +18,8 @@ @Configuration public class RateLimitConfig { + private static final Duration REFILL_EXPIRATION_STRATEGY_MAX = Duration.ofHours(2); + @Bean @ConditionalOnBean(RedisConnectionFactory.class) public ProxyManager lettuceBasedProxyManager(@Autowired(required = false) RedisConnectionFactory connectionFactory) { @@ -27,7 +29,7 @@ public ProxyManager lettuceBasedProxyManager(@Autowired(required = false if (nativeClient instanceof RedisClient redisClient) { StatefulRedisConnection connection = redisClient.connect(ByteArrayCodec.INSTANCE); return LettuceBasedProxyManager.builderFor(connection) - .withExpirationStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofHours(2))) + .withExpirationStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(REFILL_EXPIRATION_STRATEGY_MAX)) .build(); } } catch (Exception ignored) { diff --git a/backend/src/main/java/com/launchly/common/config/SecurityConfig.java b/backend/src/main/java/com/launchly/common/config/SecurityConfig.java index d2136ee7..383823f9 100644 --- a/backend/src/main/java/com/launchly/common/config/SecurityConfig.java +++ b/backend/src/main/java/com/launchly/common/config/SecurityConfig.java @@ -3,7 +3,7 @@ import com.launchly.common.ratelimit.TierRateLimitFilter; import com.launchly.common.security.CorrelationIdFilter; import com.launchly.common.security.JwtFilter; -import com.launchly.common.security.OAuth2SuccessHandler; +import com.launchly.auth.security.OAuth2SuccessHandler; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -38,9 +38,13 @@ public class SecurityConfig { @Value("${spring.security.oauth2.client.registration.google.client-id:}") private String googleClientId; + @Value("${app.frontend-url:http://localhost:5173}") + private String frontendUrl; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .headers(headers -> headers .contentTypeOptions(Customizer.withDefaults()) @@ -105,6 +109,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .redirectionEndpoint(endpoint -> endpoint .baseUri("/api/v1/auth/google/callback")) .successHandler(oAuth2SuccessHandler) + .failureHandler((request, response, exception) -> + response.sendRedirect(frontendUrl + "/login?error=oauth_failed")) ); } diff --git a/backend/src/main/java/com/launchly/common/constant/CacheConstants.java b/backend/src/main/java/com/launchly/common/constant/CacheConstants.java new file mode 100644 index 00000000..75e0a2bd --- /dev/null +++ b/backend/src/main/java/com/launchly/common/constant/CacheConstants.java @@ -0,0 +1,11 @@ +package com.launchly.common.constant; + +public final class CacheConstants { + private CacheConstants() {} + + public static final String BOTS = "bots"; + public static final String FLOW_SCHEMAS = "flow_schemas"; + public static final String SUBSCRIPTION = "subscription"; + public static final String PLANS = "plans"; + public static final String TAGS = "tags"; +} diff --git a/backend/src/main/java/com/launchly/common/constant/PaginationDefaults.java b/backend/src/main/java/com/launchly/common/constant/PaginationDefaults.java new file mode 100644 index 00000000..d2eeca45 --- /dev/null +++ b/backend/src/main/java/com/launchly/common/constant/PaginationDefaults.java @@ -0,0 +1,9 @@ +package com.launchly.common.constant; + +public final class PaginationDefaults { + private PaginationDefaults() {} + + public static final String DEFAULT_PAGE = "0"; + public static final String DEFAULT_PAGE_SIZE = "20"; + public static final String LARGE_PAGE_SIZE = "50"; +} diff --git a/backend/src/main/java/com/launchly/common/constant/PublicEndpoints.java b/backend/src/main/java/com/launchly/common/constant/PublicEndpoints.java new file mode 100644 index 00000000..9f2e7c8a --- /dev/null +++ b/backend/src/main/java/com/launchly/common/constant/PublicEndpoints.java @@ -0,0 +1,23 @@ +package com.launchly.common.constant; + +import java.util.List; + +public final class PublicEndpoints { + private PublicEndpoints() {} + + public static final List RATE_LIMIT_EXCLUDED = List.of( + "/actuator/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/ws/**", + "/api/v1/auth/**", + "/api/v1/telegram/webhook/**", + "/api/v1/billing/webhook", + "/api/v1/integrations/google/callback", + "/api/v1/integrations/hotmart/webhook/**", + "/api/v1/support/appeal", + "/api/i18n/**", + "/api/v1/templates/share/**" + ); +} diff --git a/backend/src/main/java/com/launchly/common/dto/SuccessResponse.java b/backend/src/main/java/com/launchly/common/dto/SuccessResponse.java new file mode 100644 index 00000000..59de20fb --- /dev/null +++ b/backend/src/main/java/com/launchly/common/dto/SuccessResponse.java @@ -0,0 +1,8 @@ +package com.launchly.common.dto; + +public record SuccessResponse(String status, String message) { + + public static SuccessResponse ok(String message) { + return new SuccessResponse("success", message); + } +} diff --git a/backend/src/main/java/com/launchly/common/entity/BaseEntity.java b/backend/src/main/java/com/launchly/common/entity/BaseEntity.java index d5e869f0..d8195f03 100644 --- a/backend/src/main/java/com/launchly/common/entity/BaseEntity.java +++ b/backend/src/main/java/com/launchly/common/entity/BaseEntity.java @@ -5,17 +5,24 @@ import jakarta.persistence.Id; import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.EqualsAndHashCode; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; -@Data +@Getter +@Setter +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @MappedSuperclass public abstract class BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include private Long id; @CreationTimestamp diff --git a/backend/src/main/java/com/launchly/common/idempotency/IdempotencyAspect.java b/backend/src/main/java/com/launchly/common/idempotency/IdempotencyAspect.java index 560ab5bb..e6a17dcf 100644 --- a/backend/src/main/java/com/launchly/common/idempotency/IdempotencyAspect.java +++ b/backend/src/main/java/com/launchly/common/idempotency/IdempotencyAspect.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.launchly.common.exception.AppException; import com.launchly.common.security.CustomUserDetails; import jakarta.servlet.http.HttpServletRequest; @@ -21,6 +23,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import com.fasterxml.jackson.databind.DeserializationFeature; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; @@ -31,13 +34,24 @@ @Component public class IdempotencyAspect { + private static final int MAX_KEY_LENGTH = 255; + private static final Duration DEFAULT_LOCK_DURATION = Duration.ofSeconds(60); + private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; public IdempotencyAspect(@Autowired(required = false) StringRedisTemplate stringRedisTemplate, @Autowired(required = false) ObjectMapper objectMapper) { this.stringRedisTemplate = stringRedisTemplate; - this.objectMapper = objectMapper != null ? objectMapper : new ObjectMapper(); + ObjectMapper mapper = objectMapper != null ? objectMapper : new ObjectMapper(); + try { + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } catch (Throwable ignored) { + mapper.findAndRegisterModules(); + } + this.objectMapper = mapper; } @Around("@annotation(idempotent)") @@ -60,7 +74,7 @@ public Object handleIdempotency(ProceedingJoinPoint joinPoint, Idempotent idempo } String trimmedKey = keyHeader.trim(); - if (trimmedKey.length() > 255) { + if (trimmedKey.length() > MAX_KEY_LENGTH) { throw new AppException(HttpStatus.BAD_REQUEST, "idempotency.error.invalid_key"); } @@ -69,7 +83,7 @@ public Object handleIdempotency(ProceedingJoinPoint joinPoint, Idempotent idempo String statusKey = baseKey + ":status"; String dataKey = baseKey + ":data"; - Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(statusKey, "PROCESSING", Duration.ofSeconds(60)); + Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(statusKey, "PROCESSING", DEFAULT_LOCK_DURATION); if (Boolean.TRUE.equals(acquired)) { try { diff --git a/backend/src/main/java/com/launchly/common/metric/PerformanceMonitoringFilter.java b/backend/src/main/java/com/launchly/common/metric/PerformanceMonitoringFilter.java index 13153b04..901cf175 100644 --- a/backend/src/main/java/com/launchly/common/metric/PerformanceMonitoringFilter.java +++ b/backend/src/main/java/com/launchly/common/metric/PerformanceMonitoringFilter.java @@ -47,7 +47,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha uri = ((HttpServletRequest) request).getRequestURI(); } - if (!uri.contains("/assets/") && !uri.contains("/webjars/") && !uri.endsWith(".png") && !uri.endsWith(".js") && !uri.endsWith(".css")) { + if (!uri.contains("/assets/") && !uri.contains("/webjars/") && !uri.contains("/api-docs") && !uri.contains("/swagger-ui") && !uri.endsWith(".png") && !uri.endsWith(".js") && !uri.endsWith(".css")) { String hourBucket = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00")); HourlyMetric metric = hourlyMetrics.computeIfAbsent(hourBucket, k -> new HourlyMetric()); @@ -56,6 +56,10 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha if (status >= 400) { metric.errorCount.incrementAndGet(); } + if (hourlyMetrics.size() > 48) { + String cutoff = LocalDateTime.now().minusHours(48).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00")); + hourlyMetrics.keySet().removeIf(bucket -> bucket.compareTo(cutoff) < 0); + } } } } diff --git a/backend/src/main/java/com/launchly/common/outbox/OutboxService.java b/backend/src/main/java/com/launchly/common/outbox/OutboxService.java index d79880b9..da154efd 100644 --- a/backend/src/main/java/com/launchly/common/outbox/OutboxService.java +++ b/backend/src/main/java/com/launchly/common/outbox/OutboxService.java @@ -1,8 +1,9 @@ package com.launchly.common.outbox; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.launchly.common.exception.AppException; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -11,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.databind.DeserializationFeature; import java.time.Instant; import java.util.List; @@ -25,15 +27,22 @@ public class OutboxService { public OutboxService(OutboxEventRepository outboxEventRepository, @Autowired(required = false) ObjectMapper objectMapper) { this.outboxEventRepository = outboxEventRepository; - this.objectMapper = objectMapper != null ? objectMapper : new ObjectMapper(); + if (objectMapper != null) { + this.objectMapper = objectMapper; + } else { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + this.objectMapper = mapper; + } } @Transactional(propagation = Propagation.MANDATORY) public OutboxEvent publish(String aggregateType, String aggregateId, String eventType, Object payload) { String jsonPayload; try { - ObjectMapper mapper = objectMapper != null ? objectMapper : new ObjectMapper(); - jsonPayload = mapper.writeValueAsString(payload); + jsonPayload = objectMapper.writeValueAsString(payload); } catch (Exception e) { log.error("Failed to serialize outbox event payload for {} - {}", aggregateType, aggregateId, e); jsonPayload = "{}"; diff --git a/backend/src/main/java/com/launchly/common/ratelimit/RateLimitServiceImpl.java b/backend/src/main/java/com/launchly/common/ratelimit/RateLimitServiceImpl.java index 1f93373a..3f5bd59a 100644 --- a/backend/src/main/java/com/launchly/common/ratelimit/RateLimitServiceImpl.java +++ b/backend/src/main/java/com/launchly/common/ratelimit/RateLimitServiceImpl.java @@ -8,10 +8,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; @Slf4j @@ -19,7 +20,10 @@ public class RateLimitServiceImpl implements RateLimitService { private final ProxyManager proxyManager; - private final Map localBuckets = new ConcurrentHashMap<>(); + private final Cache localBuckets = Caffeine.newBuilder() + .expireAfterAccess(Duration.ofMinutes(10)) + .maximumSize(10_000) + .build(); public RateLimitServiceImpl(@Autowired(required = false) ProxyManager proxyManager) { this.proxyManager = proxyManager; @@ -47,7 +51,7 @@ public ConsumptionProbe tryConsume(String key, long capacity, Duration duration, } } - Bucket localBucket = localBuckets.computeIfAbsent(key, k -> { + Bucket localBucket = localBuckets.get(key, k -> { Bandwidth limit = Bandwidth.builder() .capacity(capacity) .refillGreedy(capacity, duration) @@ -68,6 +72,6 @@ public boolean isAllowed(String key, long capacity, Duration duration) { @Override public void reset(String key) { - localBuckets.remove(key); + localBuckets.invalidate(key); } } \ No newline at end of file diff --git a/backend/src/main/java/com/launchly/common/ratelimit/TierRateLimitFilter.java b/backend/src/main/java/com/launchly/common/ratelimit/TierRateLimitFilter.java index 68245236..3be8c2f3 100644 --- a/backend/src/main/java/com/launchly/common/ratelimit/TierRateLimitFilter.java +++ b/backend/src/main/java/com/launchly/common/ratelimit/TierRateLimitFilter.java @@ -1,5 +1,6 @@ package com.launchly.common.ratelimit; +import com.launchly.common.constant.PublicEndpoints; import com.launchly.common.security.CustomUserDetails; import com.launchly.common.utils.MessageUtils; import jakarta.servlet.FilterChain; @@ -16,10 +17,10 @@ import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; - +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.time.Duration; -import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; @Slf4j @@ -29,28 +30,21 @@ public class TierRateLimitFilter extends OncePerRequestFilter { private final RateLimitService rateLimitService; private final MessageUtils messageUtils; + private final ObjectMapper objectMapper; private final AntPathMatcher pathMatcher = new AntPathMatcher(); - private static final List EXCLUDED_PATHS = List.of( - "/actuator/**", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/ws/**", - "/api/v1/auth/**", - "/api/v1/telegram/webhook/**", - "/api/v1/billing/webhook", - "/api/v1/integrations/google/callback", - "/api/v1/integrations/hotmart/webhook/**", - "/api/v1/support/appeal", - "/api/i18n/**", - "/api/v1/templates/share/**" - ); + private static final long ADMIN_RATE_LIMIT = 120_000; + private static final long ENTERPRISE_RATE_LIMIT = 60_000; + private static final long PRO_RATE_LIMIT = 30_000; + private static final long FREE_RATE_LIMIT = 12_000; + private static final long ANONYMOUS_RATE_LIMIT = 60; + private static final Duration WINDOW_DURATION = Duration.ofMinutes(1); + private static final long DEFAULT_CONSUME_TOKENS = 1L; @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - for (String pattern : EXCLUDED_PATHS) { + for (String pattern : PublicEndpoints.RATE_LIMIT_EXCLUDED) { if (pathMatcher.match(pattern, path)) { return true; } @@ -70,12 +64,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse capacity = resolveTierCapacity(auth); rateKey = "rate:tier:user:" + userDetails.getId(); } else { - capacity = 60; + capacity = ANONYMOUS_RATE_LIMIT; rateKey = "rate:tier:ip:" + extractClientIp(request); } - Duration duration = Duration.ofMinutes(1); - var probe = rateLimitService.tryConsume(rateKey, capacity, duration, 1); + var probe = rateLimitService.tryConsume(rateKey, capacity, WINDOW_DURATION, DEFAULT_CONSUME_TOKENS); response.setHeader("X-RateLimit-Limit", String.valueOf(capacity)); response.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens())); @@ -95,13 +88,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse retryAfterSeconds ); - String jsonResponse = String.format( - "{\"status\":429,\"error\":\"Too Many Requests\",\"message\":\"%s\",\"path\":\"%s\"}", - errorMessage.replace("\"", "\\\""), - request.getRequestURI() + Map body = Map.of( + "status", 429, + "error", "Too Many Requests", + "message", errorMessage, + "path", request.getRequestURI() ); - response.getWriter().write(jsonResponse); + response.getWriter().write(objectMapper.writeValueAsString(body)); return; } @@ -112,16 +106,16 @@ private long resolveTierCapacity(Authentication auth) { for (GrantedAuthority authority : auth.getAuthorities()) { String role = authority.getAuthority(); if ("ROLE_ADMIN".equals(role) || "ROLE_SUPER_ADMIN".equals(role)) { - return 120000; + return ADMIN_RATE_LIMIT; } if ("ROLE_ENTERPRISE".equals(role)) { - return 60000; + return ENTERPRISE_RATE_LIMIT; } if ("ROLE_PRO".equals(role)) { - return 30000; + return PRO_RATE_LIMIT; } } - return 12000; + return FREE_RATE_LIMIT; } private String extractClientIp(HttpServletRequest request) { diff --git a/backend/src/main/java/com/launchly/common/security/JwtFilter.java b/backend/src/main/java/com/launchly/common/security/JwtFilter.java index 456972ec..9d47e2fc 100644 --- a/backend/src/main/java/com/launchly/common/security/JwtFilter.java +++ b/backend/src/main/java/com/launchly/common/security/JwtFilter.java @@ -16,6 +16,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Map; @Component @RequiredArgsConstructor @@ -24,6 +25,7 @@ public class JwtFilter extends OncePerRequestFilter { private final TokenService tokenService; private final UserDetailsService userDetailsService; private final MessageUtils messageUtils; + private final tools.jackson.databind.ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -42,7 +44,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse : messageUtils.getMessage("admin.reason_rules"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write("{\"error\":\"ACCOUNT_BLOCKED\",\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}"); + Map body = Map.of("error", "ACCOUNT_BLOCKED", "reason", reason); + response.getWriter().write(objectMapper.writeValueAsString(body)); return; } diff --git a/backend/src/main/java/com/launchly/common/security/turnstile/TurnstileProperties.java b/backend/src/main/java/com/launchly/common/security/turnstile/TurnstileProperties.java new file mode 100644 index 00000000..875ab58c --- /dev/null +++ b/backend/src/main/java/com/launchly/common/security/turnstile/TurnstileProperties.java @@ -0,0 +1,16 @@ +package com.launchly.common.security.turnstile; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "cloudflare.turnstile") +@Getter +@Setter +public class TurnstileProperties { + private boolean enabled; + private String secretKey; + private String verifyUrl; +} diff --git a/backend/src/main/java/com/launchly/common/security/turnstile/TurnstileService.java b/backend/src/main/java/com/launchly/common/security/turnstile/TurnstileService.java new file mode 100644 index 00000000..df06e2cc --- /dev/null +++ b/backend/src/main/java/com/launchly/common/security/turnstile/TurnstileService.java @@ -0,0 +1,90 @@ +package com.launchly.common.security.turnstile; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TurnstileService { + + private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(5); + + private final TurnstileProperties properties; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TurnstileResponse( + boolean success, + @JsonProperty("challenge_ts") String challengeTs, + String hostname, + @JsonProperty("error-codes") List errorCodes, + String action, + String cdata + ) {} + + public boolean verifyToken(String token) { + return verifyToken(token, null); + } + + public boolean verifyToken(String token, String remoteIp) { + if (!properties.isEnabled()) { + return true; + } + + if (token == null || token.isBlank()) { + log.warn("Turnstile token is missing while verification is enabled"); + return false; + } + + try { + Map payload = new HashMap<>(); + payload.put("secret", properties.getSecretKey()); + payload.put("response", token); + if (remoteIp != null && !remoteIp.isBlank()) { + payload.put("remoteip", remoteIp); + } + + String requestBody = objectMapper.writeValueAsString(payload); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(properties.getVerifyUrl())) + .timeout(HTTP_TIMEOUT) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != HttpStatus.OK.value()) { + log.error("Turnstile API returned non-200 status code: {}", response.statusCode()); + return false; + } + + TurnstileResponse turnstileResponse = objectMapper.readValue(response.body(), TurnstileResponse.class); + if (!turnstileResponse.success()) { + log.warn("Turnstile token verification failed: error codes = {}", turnstileResponse.errorCodes()); + return false; + } + + return true; + } catch (Exception e) { + log.error("Error during Turnstile token verification: {}", e.getMessage(), e); + return false; + } + } +} diff --git a/backend/src/main/java/com/launchly/common/utils/DateTimeUtils.java b/backend/src/main/java/com/launchly/common/utils/DateTimeUtils.java index 61c201cb..3f11f862 100644 --- a/backend/src/main/java/com/launchly/common/utils/DateTimeUtils.java +++ b/backend/src/main/java/com/launchly/common/utils/DateTimeUtils.java @@ -8,6 +8,9 @@ import java.util.function.Function; import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +@Slf4j public final class DateTimeUtils { private DateTimeUtils() { @@ -20,7 +23,8 @@ public static LocalDateTime parseStart(String startDate) { } catch (Exception e) { try { return LocalDate.parse(startDate).atStartOfDay(); - } catch (Exception ignored) { + } catch (Exception ex) { + log.warn("Failed to parse start date: {}", ex.getMessage()); return null; } } @@ -33,7 +37,8 @@ public static LocalDateTime parseEnd(String endDate) { } catch (Exception e) { try { return LocalDate.parse(endDate).atTime(LocalTime.MAX); - } catch (Exception ignored) { + } catch (Exception ex) { + log.warn("Failed to parse end date: {}", ex.getMessage()); return null; } } diff --git a/backend/src/main/java/com/launchly/common/utils/EncryptionUtil.java b/backend/src/main/java/com/launchly/common/utils/EncryptionUtil.java index 17e20a62..5f48065b 100644 --- a/backend/src/main/java/com/launchly/common/utils/EncryptionUtil.java +++ b/backend/src/main/java/com/launchly/common/utils/EncryptionUtil.java @@ -64,7 +64,7 @@ public String encrypt(String plaintext) { return Base64.getEncoder().encodeToString(buffer.array()); } catch (Exception e) { - throw new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to encrypt data"); + throw new AppException(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.encryption_failed"); } } diff --git a/backend/src/main/java/com/launchly/common/utils/JsonUtils.java b/backend/src/main/java/com/launchly/common/utils/JsonUtils.java index d1d81e1e..3180669e 100644 --- a/backend/src/main/java/com/launchly/common/utils/JsonUtils.java +++ b/backend/src/main/java/com/launchly/common/utils/JsonUtils.java @@ -2,6 +2,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.databind.DeserializationFeature; import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.Collections; @@ -10,7 +13,15 @@ @Slf4j public final class JsonUtils { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER; + + static { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + OBJECT_MAPPER = mapper; + } private JsonUtils() { } diff --git a/backend/src/main/java/com/launchly/crm/controller/CrmController.java b/backend/src/main/java/com/launchly/crm/controller/CrmController.java index 994bb9c6..458ade3e 100644 --- a/backend/src/main/java/com/launchly/crm/controller/CrmController.java +++ b/backend/src/main/java/com/launchly/crm/controller/CrmController.java @@ -6,6 +6,7 @@ import com.launchly.common.security.CustomUserDetails; import com.launchly.crm.dto.request.AddNoteRequest; import com.launchly.crm.dto.request.ConversationUpdateRequest; +import com.launchly.crm.dto.request.CreateLabelRequest; import com.launchly.crm.dto.request.LeadUpdateRequest; import com.launchly.crm.dto.request.OrderUpdateRequest; import com.launchly.crm.dto.request.SendMessageRequest; @@ -24,6 +25,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -57,24 +59,25 @@ public ResponseEntity> getLabels( @Operation(summary = "Add CRM label", description = "Create a new custom label for conversation filtering.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Updated list of labels") + @ApiResponse(responseCode = "201", description = "Label added successfully") }) @PostMapping("/labels") public ResponseEntity> addLabel( - @RequestBody java.util.Map request, + @Valid @RequestBody CreateLabelRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { - return ResponseEntity.ok(crmService.addLabel(request.get("name"), userDetails.getId())); + return ResponseEntity.status(HttpStatus.CREATED).body(crmService.addLabel(request.name(), userDetails.getId())); } @Operation(summary = "Delete CRM label", description = "Remove an existing custom conversation label.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Updated list of labels") + @ApiResponse(responseCode = "204", description = "Updated list of labels") }) @DeleteMapping("/labels/{name}") - public ResponseEntity> deleteLabel( + public ResponseEntity deleteLabel( @Parameter(description = "Label name") @PathVariable String name, @AuthenticationPrincipal CustomUserDetails userDetails) { - return ResponseEntity.ok(crmService.deleteLabel(name, userDetails.getId())); + crmService.deleteLabel(name, userDetails.getId()); + return ResponseEntity.noContent().build(); } @Operation(summary = "Get bot orders", description = "Retrieve all e-commerce customer orders generated across bot workflows.") diff --git a/backend/src/main/java/com/launchly/crm/dto/request/CreateLabelRequest.java b/backend/src/main/java/com/launchly/crm/dto/request/CreateLabelRequest.java new file mode 100644 index 00000000..f718a7e3 --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/dto/request/CreateLabelRequest.java @@ -0,0 +1,13 @@ +package com.launchly.crm.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request payload to create a custom CRM label") +public record CreateLabelRequest( + @Schema(description = "Label name", example = "VIP Customer", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "validation.label.name.required") + @Size(max = 100, message = "validation.label.name.size") + String name +) {} diff --git a/backend/src/main/java/com/launchly/crm/dto/request/SendMessageRequest.java b/backend/src/main/java/com/launchly/crm/dto/request/SendMessageRequest.java index def47c7e..a3d504eb 100644 --- a/backend/src/main/java/com/launchly/crm/dto/request/SendMessageRequest.java +++ b/backend/src/main/java/com/launchly/crm/dto/request/SendMessageRequest.java @@ -1,7 +1,9 @@ package com.launchly.crm.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import java.time.LocalDateTime; @Schema(description = "Request payload for an agent to send or schedule a Live Chat message") @@ -14,9 +16,10 @@ public record SendMessageRequest( String mediaUrl, @Schema(description = "Media MIME/type: IMAGE, DOCUMENT, AUDIO, VIDEO", example = "IMAGE") + @Pattern(regexp = "^(?i)(IMAGE|DOCUMENT|AUDIO|VIDEO)?$", message = "Invalid media type. Allowed values: IMAGE, DOCUMENT, AUDIO, VIDEO") String mediaType, @Schema(description = "Optional future dispatch timestamp for scheduled messages") + @Future(message = "Scheduled time must be in the future") LocalDateTime scheduledAt ) {} - diff --git a/backend/src/main/java/com/launchly/crm/entity/Conversation.java b/backend/src/main/java/com/launchly/crm/entity/Conversation.java index 4a585123..77857009 100644 --- a/backend/src/main/java/com/launchly/crm/entity/Conversation.java +++ b/backend/src/main/java/com/launchly/crm/entity/Conversation.java @@ -18,7 +18,8 @@ import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; @@ -37,9 +38,10 @@ @Index(name = "idx_conversations_bot_updated", columnList = "bot_id, updated_at DESC"), @Index(name = "idx_conversations_bot_status", columnList = "bot_id, status, updated_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true, exclude = {"messages"}) -@ToString(exclude = {"messages"}) +@Getter +@Setter +@ToString(exclude = {"messages", "bot", "botUser"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/crm/entity/CrmLabel.java b/backend/src/main/java/com/launchly/crm/entity/CrmLabel.java index 0b9510e4..38bf879d 100644 --- a/backend/src/main/java/com/launchly/crm/entity/CrmLabel.java +++ b/backend/src/main/java/com/launchly/crm/entity/CrmLabel.java @@ -11,15 +11,19 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; @Entity @Table(name = "crm_labels", indexes = { @Index(name = "idx_crm_labels_user_id", columnList = "user_id")}) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"user"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/crm/entity/Lead.java b/backend/src/main/java/com/launchly/crm/entity/Lead.java index ee5e5e13..22c80975 100644 --- a/backend/src/main/java/com/launchly/crm/entity/Lead.java +++ b/backend/src/main/java/com/launchly/crm/entity/Lead.java @@ -15,7 +15,9 @@ import jakarta.persistence.Version; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; @@ -30,8 +32,10 @@ @Index(name = "idx_leads_bot_created", columnList = "bot_id, created_at DESC"), @Index(name = "idx_leads_bot_user_bot", columnList = "bot_user_id, bot_id") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot", "botUser"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/crm/entity/Message.java b/backend/src/main/java/com/launchly/crm/entity/Message.java index dfd9bf19..340f50b8 100644 --- a/backend/src/main/java/com/launchly/crm/entity/Message.java +++ b/backend/src/main/java/com/launchly/crm/entity/Message.java @@ -13,7 +13,9 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -23,8 +25,10 @@ @Index(name = "idx_messages_created_at", columnList = "created_at ASC"), @Index(name = "idx_messages_conversation_created", columnList = "conversation_id, created_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"conversation"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/crm/entity/Order.java b/backend/src/main/java/com/launchly/crm/entity/Order.java index 28782197..a38a4f65 100644 --- a/backend/src/main/java/com/launchly/crm/entity/Order.java +++ b/backend/src/main/java/com/launchly/crm/entity/Order.java @@ -15,7 +15,9 @@ import jakarta.persistence.Version; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; @@ -31,8 +33,10 @@ @Index(name = "idx_orders_bot_status", columnList = "bot_id, status"), @Index(name = "idx_orders_bot_created", columnList = "bot_id, created_at DESC") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot", "botUser"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/crm/event/CrmOutgoingMessageEvent.java b/backend/src/main/java/com/launchly/crm/event/CrmOutgoingMessageEvent.java new file mode 100644 index 00000000..a6346436 --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/event/CrmOutgoingMessageEvent.java @@ -0,0 +1,8 @@ +package com.launchly.crm.event; + +public record CrmOutgoingMessageEvent( + Long botId, + Long telegramUserId, + String content, + String mediaUrl +) {} diff --git a/backend/src/main/java/com/launchly/crm/listener/CrmTelegramMessageListener.java b/backend/src/main/java/com/launchly/crm/listener/CrmTelegramMessageListener.java new file mode 100644 index 00000000..a84a6cae --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/listener/CrmTelegramMessageListener.java @@ -0,0 +1,29 @@ +package com.launchly.crm.listener; + +import com.launchly.bot.service.TelegramSendService; +import com.launchly.crm.event.CrmOutgoingMessageEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CrmTelegramMessageListener { + + private final TelegramSendService telegramSendService; + + @EventListener + public void handleOutgoingMessage(CrmOutgoingMessageEvent event) { + try { + if (event.mediaUrl() != null && !event.mediaUrl().isBlank()) { + telegramSendService.sendPhoto(event.botId(), event.telegramUserId(), event.mediaUrl(), event.content()); + } else { + telegramSendService.sendMessage(event.botId(), event.telegramUserId(), event.content()); + } + } catch (Exception e) { + log.error("Failed to send telegram message for bot {}: {}", event.botId(), e.getMessage(), e); + } + } +} diff --git a/backend/src/main/java/com/launchly/crm/mapper/CrmMapper.java b/backend/src/main/java/com/launchly/crm/mapper/CrmMapper.java index b9ec7b3d..31d47e04 100644 --- a/backend/src/main/java/com/launchly/crm/mapper/CrmMapper.java +++ b/backend/src/main/java/com/launchly/crm/mapper/CrmMapper.java @@ -13,13 +13,13 @@ @Mapper(componentModel = "spring") public interface CrmMapper { - @Mapping(target = "botUserName", expression = "java(order.getBotUser().getFirstName() + (order.getBotUser().getLastName() != null ? \" \" + order.getBotUser().getLastName() : \"\"))") + @Mapping(target = "botUserName", expression = "java(order.getBotUser() != null ? order.getBotUser().getDisplayName() : null)") @Mapping(target = "botUserUsername", source = "botUser.username") OrderResponse toOrderResponse(Order order); List toOrderResponseList(List orders); - @Mapping(target = "botUserName", expression = "java(lead.getBotUser().getFirstName() + (lead.getBotUser().getLastName() != null ? \" \" + lead.getBotUser().getLastName() : \"\"))") + @Mapping(target = "botUserName", expression = "java(lead.getBotUser() != null ? lead.getBotUser().getDisplayName() : null)") @Mapping(target = "botUserUsername", source = "botUser.username") LeadResponse toLeadResponse(Lead lead); diff --git a/backend/src/main/java/com/launchly/crm/repository/ConversationRepository.java b/backend/src/main/java/com/launchly/crm/repository/ConversationRepository.java index ff5ab6b3..96f0a5bb 100644 --- a/backend/src/main/java/com/launchly/crm/repository/ConversationRepository.java +++ b/backend/src/main/java/com/launchly/crm/repository/ConversationRepository.java @@ -10,18 +10,18 @@ public interface ConversationRepository extends JpaRepository { - @EntityGraph(attributePaths = {"botUser", "bot"}) + @EntityGraph(attributePaths = {"bot", "botUser"}) List findByBotIdOrderByUpdatedAtDesc(Long botId); - @EntityGraph(attributePaths = {"botUser", "bot"}) - @Query("SELECT c FROM Conversation c WHERE c.bot.user.id = :userId ORDER BY c.updatedAt DESC") + @EntityGraph(attributePaths = {"bot", "botUser"}) + @Query("SELECT c FROM Conversation c WHERE (c.bot.user.id = :userId OR EXISTS (SELECT 1 FROM BotMember bm WHERE bm.bot.user.id = c.bot.user.id AND bm.user.id = :userId)) ORDER BY c.updatedAt DESC") List findByBotUserIdOrderByUpdatedAtDesc(@Param("userId") Long userId); - @EntityGraph(attributePaths = {"botUser", "bot"}) + @EntityGraph(attributePaths = {"bot", "botUser"}) Optional findByBotIdAndBotUserId(Long botId, Long botUserId); @Override - @EntityGraph(attributePaths = {"botUser", "bot"}) + @EntityGraph(attributePaths = {"bot", "botUser"}) Optional findById(Long id); long countByBotId(Long botId); diff --git a/backend/src/main/java/com/launchly/crm/repository/MessageRepository.java b/backend/src/main/java/com/launchly/crm/repository/MessageRepository.java index a5e93a90..464a84e6 100644 --- a/backend/src/main/java/com/launchly/crm/repository/MessageRepository.java +++ b/backend/src/main/java/com/launchly/crm/repository/MessageRepository.java @@ -3,7 +3,10 @@ import com.launchly.crm.entity.Message; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -17,5 +20,8 @@ public interface MessageRepository extends JpaRepository { @EntityGraph(attributePaths = {"conversation", "conversation.bot"}) List findBySentFalseAndScheduledAtBefore(LocalDateTime dateTime); -} + @EntityGraph(attributePaths = {"conversation"}) + @Query("SELECT m FROM Message m WHERE m.id IN (SELECT MAX(m2.id) FROM Message m2 WHERE m2.conversation.id IN :conversationIds GROUP BY m2.conversation.id)") + List findLatestMessagesByConversationIds(@Param("conversationIds") Collection conversationIds); +} diff --git a/backend/src/main/java/com/launchly/crm/scheduler/CrmMessageScheduler.java b/backend/src/main/java/com/launchly/crm/scheduler/CrmMessageScheduler.java index f2117865..0d325f75 100644 --- a/backend/src/main/java/com/launchly/crm/scheduler/CrmMessageScheduler.java +++ b/backend/src/main/java/com/launchly/crm/scheduler/CrmMessageScheduler.java @@ -11,9 +11,11 @@ @Slf4j public class CrmMessageScheduler { + private static final long SCHEDULED_MESSAGES_CHECK_DELAY_MS = 15_000L; + private final CrmService crmService; - @Scheduled(fixedDelay = 15000) + @Scheduled(fixedDelay = SCHEDULED_MESSAGES_CHECK_DELAY_MS) public void processScheduledMessages() { try { crmService.sendScheduledMessages(); diff --git a/backend/src/main/java/com/launchly/crm/service/CrmConversationService.java b/backend/src/main/java/com/launchly/crm/service/CrmConversationService.java new file mode 100644 index 00000000..442f5033 --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/CrmConversationService.java @@ -0,0 +1,22 @@ +package com.launchly.crm.service; + +import com.launchly.crm.dto.request.ConversationUpdateRequest; +import com.launchly.crm.dto.response.ConversationResponse; +import com.launchly.crm.entity.Conversation; + +import java.util.List; + +public interface CrmConversationService { + + List getConversationsByBot(Long botId, Long userId); + + List getAllConversations(Long userId); + + ConversationResponse getConversation(Long conversationId, Long userId); + + ConversationResponse updateConversation(Long conversationId, ConversationUpdateRequest request, Long userId); + + Conversation getOrCreateConversation(Long botId, Long botUserId); + + Conversation getConversationOrThrow(Long conversationId, Long userId); +} diff --git a/backend/src/main/java/com/launchly/crm/service/CrmLabelService.java b/backend/src/main/java/com/launchly/crm/service/CrmLabelService.java new file mode 100644 index 00000000..48d750d4 --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/CrmLabelService.java @@ -0,0 +1,12 @@ +package com.launchly.crm.service; + +import java.util.List; + +public interface CrmLabelService { + + List getLabels(Long userId); + + List addLabel(String name, Long userId); + + List deleteLabel(String name, Long userId); +} diff --git a/backend/src/main/java/com/launchly/crm/service/CrmMessageService.java b/backend/src/main/java/com/launchly/crm/service/CrmMessageService.java new file mode 100644 index 00000000..34b3155b --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/CrmMessageService.java @@ -0,0 +1,24 @@ +package com.launchly.crm.service; + +import com.launchly.crm.dto.request.AddNoteRequest; +import com.launchly.crm.dto.request.SendMessageRequest; +import com.launchly.crm.dto.response.MessageResponse; + +import java.util.List; + +public interface CrmMessageService { + + List getMessages(Long conversationId, Long userId); + + MessageResponse saveIncomingMessage(Long botId, Long botUserId, String content); + + MessageResponse saveBotMessage(Long botId, Long botUserId, String content); + + MessageResponse saveBotMessage(Long botId, Long botUserId, String content, String mediaUrl, String mediaType); + + MessageResponse sendOwnerMessage(Long conversationId, SendMessageRequest request, Long userId); + + MessageResponse addNote(Long conversationId, AddNoteRequest request, Long userId); + + void sendScheduledMessages(); +} diff --git a/backend/src/main/java/com/launchly/crm/service/CrmPipelineService.java b/backend/src/main/java/com/launchly/crm/service/CrmPipelineService.java new file mode 100644 index 00000000..1f7945da --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/CrmPipelineService.java @@ -0,0 +1,19 @@ +package com.launchly.crm.service; + +import com.launchly.crm.dto.request.LeadUpdateRequest; +import com.launchly.crm.dto.request.OrderUpdateRequest; +import com.launchly.crm.dto.response.LeadResponse; +import com.launchly.crm.dto.response.OrderResponse; + +import java.math.BigDecimal; +import java.util.List; + +public interface CrmPipelineService { + OrderResponse createOrder(Long botId, Long botUserId, String items, BigDecimal totalAmount, String currency); + List getOrdersByBot(Long botId, Long userId); + OrderResponse updateOrder(Long orderId, OrderUpdateRequest request, Long userId); + + LeadResponse createLead(Long botId, Long botUserId, String name, String email, String phone, String data); + List getLeadsByBot(Long botId, Long userId); + LeadResponse updateLead(Long leadId, LeadUpdateRequest request, Long userId); +} diff --git a/backend/src/main/java/com/launchly/crm/service/impl/CrmConversationServiceImpl.java b/backend/src/main/java/com/launchly/crm/service/impl/CrmConversationServiceImpl.java new file mode 100644 index 00000000..0c73a903 --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/impl/CrmConversationServiceImpl.java @@ -0,0 +1,193 @@ +package com.launchly.crm.service.impl; + +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.common.exception.AppException; +import com.launchly.crm.dto.request.ConversationUpdateRequest; +import com.launchly.crm.dto.response.ConversationResponse; +import com.launchly.crm.dto.response.MessageResponse; +import com.launchly.crm.entity.Conversation; +import com.launchly.crm.entity.Message; +import com.launchly.crm.entity.SenderType; +import com.launchly.crm.repository.ConversationRepository; +import com.launchly.crm.repository.MessageRepository; +import com.launchly.crm.service.CrmConversationService; +import com.launchly.crm.websocket.CrmWebSocketService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CrmConversationServiceImpl implements CrmConversationService { + + private static final Long SYSTEM_EVENT_MESSAGE_ID = -1L; + private static final String STATUS_UPDATED_EVENT = "status_updated"; + + private final ConversationRepository conversationRepository; + private final MessageRepository messageRepository; + private final BotRepository botRepository; + private final BotUserRepository botUserRepository; + private final CrmWebSocketService webSocketService; + + @Override + @Transactional(readOnly = true) + public List getConversationsByBot(Long botId, Long userId) { + verifyBotOwnership(botId, userId); + List conversations = conversationRepository.findByBotIdOrderByUpdatedAtDesc(botId); + return toConversationResponseList(conversations); + } + + @Override + @Transactional(readOnly = true) + public List getAllConversations(Long userId) { + List conversations = conversationRepository.findByBotUserIdOrderByUpdatedAtDesc(userId); + return toConversationResponseList(conversations); + } + + @Override + @Transactional(readOnly = true) + public ConversationResponse getConversation(Long conversationId, Long userId) { + Conversation conversation = getConversationOrThrow(conversationId, userId); + return toConversationResponse(conversation); + } + + @Override + @Transactional + public ConversationResponse updateConversation(Long conversationId, ConversationUpdateRequest request, Long userId) { + Conversation conversation = getConversationOrThrow(conversationId, userId); + + if (request.status() != null) { + conversation.setStatus(request.status()); + } + if (request.unread() != null) { + conversation.setUnread(request.unread()); + } + if (request.favorite() != null) { + conversation.setFavorite(request.favorite()); + } + if (request.tags() != null) { + conversation.setTags(request.tags()); + } + if (request.notes() != null) { + conversation.setNotes(request.notes()); + } + + conversation = conversationRepository.save(conversation); + ConversationResponse response = toConversationResponse(conversation); + MessageResponse wsNotify = new MessageResponse( + SYSTEM_EVENT_MESSAGE_ID, + conversation.getId(), + STATUS_UPDATED_EVENT, + SenderType.OWNER, + null, + null, + LocalDateTime.now(), + null, + true + ); + webSocketService.notifyNewMessage(conversation.getBot().getId(), wsNotify); + + return response; + } + + @Override + @Transactional + public Conversation getOrCreateConversation(Long botId, Long botUserId) { + return conversationRepository.findByBotIdAndBotUserId(botId, botUserId) + .orElseGet(() -> { + Bot bot = botRepository.findById(botId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.not_found")); + BotUser botUser = botUserRepository.findById(botUserId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.contact_not_found")); + + Conversation conversation = Conversation.builder() + .bot(bot) + .botUser(botUser) + .build(); + return conversationRepository.save(conversation); + }); + } + + @Override + @Transactional(readOnly = true) + public Conversation getConversationOrThrow(Long conversationId, Long userId) { + Conversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "crm.error.conversation_not_found")); + verifyBotOwnership(conversation.getBot().getId(), userId); + return conversation; + } + + private void verifyBotOwnership(Long botId, Long userId) { + botRepository.findByIdAndUserId(botId, userId) + .orElseThrow(() -> new AppException(HttpStatus.FORBIDDEN, "bot.error.access_denied")); + } + + private List toConversationResponseList(List conversations) { + if (conversations == null || conversations.isEmpty()) { + return List.of(); + } + + List conversationIds = conversations.stream().map(Conversation::getId).toList(); + Map latestMessages = new HashMap<>(); + try { + List messages = messageRepository.findLatestMessagesByConversationIds(conversationIds); + for (Message m : messages) { + if (m.getConversation() != null) { + latestMessages.put(m.getConversation().getId(), m); + } + } + } catch (Exception e) { + log.error("Failed to batch load latest messages for conversations: {}", e.getMessage(), e); + } + + return conversations.stream() + .map(c -> toConversationResponseWithLastMessage(c, latestMessages.get(c.getId()))) + .toList(); + } + + private ConversationResponse toConversationResponseWithLastMessage(Conversation conversation, Message last) { + BotUser botUser = conversation.getBotUser(); + String botUserName = botUser.getDisplayName(); + + String lastMessage = null; + LocalDateTime lastMessageAt = null; + if (last != null) { + lastMessage = last.getContent(); + lastMessageAt = last.getCreatedAt(); + } + + return new ConversationResponse( + conversation.getId(), + conversation.getStatus(), + conversation.isUnread(), + conversation.isFavorite(), + conversation.getTags() != null ? conversation.getTags() : List.of(), + conversation.getNotes(), + botUserName, + botUser.getUsername(), + botUser.getTelegramId(), + botUser.getPhotoUrl(), + lastMessage, + lastMessageAt, + conversation.getUpdatedAt(), + conversation.getBot().getId(), + conversation.getBot().getName() + ); + } + + private ConversationResponse toConversationResponse(Conversation conversation) { + Message last = messageRepository.findFirstByConversationIdOrderByCreatedAtDesc(conversation.getId()).orElse(null); + return toConversationResponseWithLastMessage(conversation, last); + } +} diff --git a/backend/src/main/java/com/launchly/crm/service/impl/CrmLabelServiceImpl.java b/backend/src/main/java/com/launchly/crm/service/impl/CrmLabelServiceImpl.java new file mode 100644 index 00000000..edc7991f --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/impl/CrmLabelServiceImpl.java @@ -0,0 +1,56 @@ +package com.launchly.crm.service.impl; + +import com.launchly.auth.entity.User; +import com.launchly.auth.repository.UserRepository; +import com.launchly.common.exception.AppException; +import com.launchly.crm.entity.CrmLabel; +import com.launchly.crm.repository.CrmLabelRepository; +import com.launchly.crm.service.CrmLabelService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CrmLabelServiceImpl implements CrmLabelService { + + private final CrmLabelRepository crmLabelRepository; + private final UserRepository userRepository; + + @Override + @Transactional(readOnly = true) + public List getLabels(Long userId) { + return crmLabelRepository.findByUserId(userId).stream() + .map(CrmLabel::getName) + .toList(); + } + + @Override + @Transactional + public List addLabel(String name, Long userId) { + if (name != null && !name.isBlank()) { + String trimmed = name.trim(); + if (crmLabelRepository.findByUserIdAndName(userId, trimmed).isEmpty()) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "auth.error.user_not_found")); + crmLabelRepository.save(CrmLabel.builder() + .name(trimmed) + .user(user) + .build()); + } + } + return getLabels(userId); + } + + @Override + @Transactional + public List deleteLabel(String name, Long userId) { + if (name != null && !name.isBlank()) { + crmLabelRepository.deleteByUserIdAndName(userId, name.trim()); + } + return getLabels(userId); + } +} diff --git a/backend/src/main/java/com/launchly/crm/service/impl/CrmMessageServiceImpl.java b/backend/src/main/java/com/launchly/crm/service/impl/CrmMessageServiceImpl.java new file mode 100644 index 00000000..1dc933aa --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/impl/CrmMessageServiceImpl.java @@ -0,0 +1,214 @@ +package com.launchly.crm.service.impl; + +import com.launchly.auth.entity.User; +import com.launchly.crm.dto.request.AddNoteRequest; +import com.launchly.crm.dto.request.SendMessageRequest; +import com.launchly.crm.dto.response.MessageResponse; +import com.launchly.crm.entity.Conversation; +import com.launchly.crm.entity.Message; +import com.launchly.crm.entity.SenderType; +import com.launchly.crm.event.CrmOutgoingMessageEvent; +import com.launchly.crm.mapper.CrmMapper; +import com.launchly.crm.repository.MessageRepository; +import com.launchly.crm.service.CrmConversationService; +import com.launchly.crm.service.CrmMessageService; +import com.launchly.crm.websocket.CrmWebSocketService; +import com.launchly.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CrmMessageServiceImpl implements CrmMessageService { + + private static final Duration SCHEDULED_MESSAGE_LOCK_TIMEOUT = Duration.ofMinutes(5); + + private final MessageRepository messageRepository; + private final CrmConversationService conversationService; + private final CrmMapper crmMapper; + private final CrmWebSocketService webSocketService; + private final NotificationService notificationService; + private final StringRedisTemplate stringRedisTemplate; + private final ApplicationEventPublisher eventPublisher; + + @Override + @Transactional(readOnly = true) + public List getMessages(Long conversationId, Long userId) { + Conversation conversation = conversationService.getConversationOrThrow(conversationId, userId); + List messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId()); + return crmMapper.toMessageResponseList(messages); + } + + @Override + @Transactional + public MessageResponse saveIncomingMessage(Long botId, Long botUserId, String content) { + Conversation conversation = conversationService.getOrCreateConversation(botId, botUserId); + conversation.setUnread(true); + + Message message = Message.builder() + .content(content) + .senderType(SenderType.BOT_USER) + .conversation(conversation) + .build(); + message = messageRepository.save(message); + MessageResponse response = crmMapper.toMessageResponse(message); + webSocketService.notifyNewMessage(botId, response); + + try { + User botOwner = conversation.getBot().getUser(); + if (botOwner != null) { + notificationService.sendNewMessageNotification(botOwner.getId(), conversation.getId(), content); + } + } catch (Exception e) { + log.error("Failed to send incoming message notification for conversation {}: {}", conversation.getId(), e.getMessage(), e); + } + + return response; + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public MessageResponse saveBotMessage(Long botId, Long botUserId, String content) { + return saveBotMessage(botId, botUserId, content, null, null); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public MessageResponse saveBotMessage(Long botId, Long botUserId, String content, String mediaUrl, String mediaType) { + Conversation conversation = conversationService.getOrCreateConversation(botId, botUserId); + + Message message = Message.builder() + .content(content != null ? content : "") + .senderType(SenderType.OWNER) + .mediaUrl(mediaUrl) + .mediaType(mediaType) + .conversation(conversation) + .build(); + message = messageRepository.save(message); + MessageResponse response = crmMapper.toMessageResponse(message); + webSocketService.notifyNewMessage(botId, response); + return response; + } + + @Override + @Transactional + public MessageResponse sendOwnerMessage(Long conversationId, SendMessageRequest request, Long userId) { + Conversation conversation = conversationService.getConversationOrThrow(conversationId, userId); + + Message.MessageBuilder builder = Message.builder() + .content(request.content()) + .senderType(SenderType.OWNER) + .conversation(conversation); + + if (request.mediaUrl() != null && !request.mediaUrl().isBlank()) { + builder.mediaUrl(request.mediaUrl()); + builder.mediaType(request.mediaType() != null ? request.mediaType() : "image"); + } + + if (request.scheduledAt() != null && request.scheduledAt().isAfter(LocalDateTime.now())) { + builder.scheduledAt(request.scheduledAt()); + builder.sent(false); + } else { + builder.sent(true); + } + + Message message = builder.build(); + message = messageRepository.save(message); + log.info("Owner saved message to conversation {} in DB (scheduled={})", conversationId, request.scheduledAt() != null); + + final Message savedMessage = message; + final MessageResponse response = crmMapper.toMessageResponse(savedMessage); + final Long botId = conversation.getBot().getId(); + final Long telegramUserId = conversation.getBotUser().getTelegramId(); + final String mediaUrl = request.mediaUrl(); + final String content = request.content(); + final boolean isSent = savedMessage.getSent(); + + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + if (isSent) { + eventPublisher.publishEvent(new CrmOutgoingMessageEvent(botId, telegramUserId, content, mediaUrl)); + } + webSocketService.notifyNewMessage(botId, response); + } + }); + } else { + if (isSent) { + eventPublisher.publishEvent(new CrmOutgoingMessageEvent(botId, telegramUserId, content, mediaUrl)); + } + webSocketService.notifyNewMessage(botId, response); + } + + return response; + } + + @Override + @Transactional + public MessageResponse addNote(Long conversationId, AddNoteRequest request, Long userId) { + Conversation conversation = conversationService.getConversationOrThrow(conversationId, userId); + + Message message = Message.builder() + .content(request.content()) + .senderType(SenderType.NOTE) + .conversation(conversation) + .build(); + message = messageRepository.save(message); + log.info("Owner added note to conversation {} in DB", conversationId); + + MessageResponse response = crmMapper.toMessageResponse(message); + webSocketService.notifyNewMessage(conversation.getBot().getId(), response); + return response; + } + + @Override + @Transactional + public void sendScheduledMessages() { + List dueMessages = messageRepository.findBySentFalseAndScheduledAtBefore(LocalDateTime.now()); + if (dueMessages.isEmpty()) { + return; + } + + log.info("Found {} scheduled messages ready to send", dueMessages.size()); + + for (Message message : dueMessages) { + String lockKey = "lock:crm:scheduled:" + message.getId(); + Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", SCHEDULED_MESSAGE_LOCK_TIMEOUT); + if (Boolean.FALSE.equals(acquired)) { + continue; + } + + try { + Conversation conversation = message.getConversation(); + eventPublisher.publishEvent(new CrmOutgoingMessageEvent( + conversation.getBot().getId(), + conversation.getBotUser().getTelegramId(), + message.getContent(), + message.getMediaUrl() + )); + + message.setSent(true); + messageRepository.save(message); + log.info("Dispatched scheduled message id={}", message.getId()); + + MessageResponse response = crmMapper.toMessageResponse(message); + webSocketService.notifyNewMessage(conversation.getBot().getId(), response); + } catch (Exception e) { + log.error("Failed to send scheduled message id={}: {}", message.getId(), e.getMessage(), e); + stringRedisTemplate.delete(lockKey); + } + } + } +} diff --git a/backend/src/main/java/com/launchly/crm/service/impl/CrmPipelineServiceImpl.java b/backend/src/main/java/com/launchly/crm/service/impl/CrmPipelineServiceImpl.java new file mode 100644 index 00000000..ded98611 --- /dev/null +++ b/backend/src/main/java/com/launchly/crm/service/impl/CrmPipelineServiceImpl.java @@ -0,0 +1,213 @@ +package com.launchly.crm.service.impl; + +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.common.exception.AppException; +import com.launchly.common.outbox.OutboxService; +import com.launchly.crm.dto.request.LeadUpdateRequest; +import com.launchly.crm.dto.request.OrderUpdateRequest; +import com.launchly.crm.dto.response.LeadResponse; +import com.launchly.crm.dto.response.OrderResponse; +import com.launchly.crm.entity.Lead; +import com.launchly.crm.entity.Order; +import com.launchly.crm.mapper.CrmMapper; +import com.launchly.crm.repository.LeadRepository; +import com.launchly.crm.repository.OrderRepository; +import com.launchly.crm.service.CrmPipelineService; +import com.launchly.crm.websocket.CrmWebSocketService; +import com.launchly.integration.service.IntegrationEventService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.math.BigDecimal; +import java.util.List; + +@Slf4j +@Service +public class CrmPipelineServiceImpl implements CrmPipelineService { + + private final OrderRepository orderRepository; + private final LeadRepository leadRepository; + private final BotRepository botRepository; + private final BotUserRepository botUserRepository; + private final CrmMapper crmMapper; + private final CrmWebSocketService webSocketService; + private final IntegrationEventService integrationEventService; + private final OutboxService outboxService; + + @Autowired + public CrmPipelineServiceImpl(OrderRepository orderRepository, + LeadRepository leadRepository, + BotRepository botRepository, + BotUserRepository botUserRepository, + CrmMapper crmMapper, + CrmWebSocketService webSocketService, + IntegrationEventService integrationEventService, + @Autowired(required = false) OutboxService outboxService) { + this.orderRepository = orderRepository; + this.leadRepository = leadRepository; + this.botRepository = botRepository; + this.botUserRepository = botUserRepository; + this.crmMapper = crmMapper; + this.webSocketService = webSocketService; + this.integrationEventService = integrationEventService; + this.outboxService = outboxService; + } + + @Override + @Transactional + public OrderResponse createOrder(Long botId, Long botUserId, String items, + BigDecimal totalAmount, String currency) { + Bot bot = botRepository.findById(botId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.not_found")); + BotUser botUser = botUserRepository.findById(botUserId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.contact_not_found")); + Long nextNumber = bot.getOrderSequence() + 1; + bot.setOrderSequence(nextNumber); + botRepository.save(bot); + String orderNumber = "#" + nextNumber; + Order order = Order.builder() + .orderNumber(orderNumber) + .items(items) + .totalAmount(totalAmount != null ? totalAmount : BigDecimal.ZERO) + .currency(currency != null ? currency : "UAH") + .bot(bot) + .botUser(botUser) + .build(); + + order = orderRepository.save(order); + log.info("Created order {} for bot {} by bot user {}", orderNumber, botId, botUserId); + + final Order savedOrder = order; + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + integrationEventService.onOrderCreated(savedOrder); + } + }); + } else { + integrationEventService.onOrderCreated(savedOrder); + } + + OrderResponse response = crmMapper.toOrderResponse(order); + if (outboxService != null) { + outboxService.publish("CRM_ORDER", String.valueOf(order.getId()), "ORDER_CREATED", response); + } + webSocketService.notifyNewOrder(botId, response); + return response; + } + + @Override + @Transactional(readOnly = true) + public List getOrdersByBot(Long botId, Long userId) { + verifyBotOwnership(botId, userId); + List orders = orderRepository.findByBotIdOrderByCreatedAtDesc(botId); + return crmMapper.toOrderResponseList(orders); + } + + @Override + @Transactional + public OrderResponse updateOrder(Long orderId, OrderUpdateRequest request, Long userId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "crm.error.order_not_found")); + + verifyBotOwnership(order.getBot().getId(), userId); + + if (request.status() != null) { + order.setStatus(request.status()); + } + if (request.notes() != null) { + order.setNotes(request.notes()); + } + + order = orderRepository.save(order); + log.info("Updated order {} status={}", order.getOrderNumber(), order.getStatus()); + OrderResponse response = crmMapper.toOrderResponse(order); + webSocketService.notifyOrderUpdate(order.getBot().getId(), response); + return response; + } + + @Override + @Transactional + public LeadResponse createLead(Long botId, Long botUserId, String name, + String email, String phone, String data) { + Bot bot = botRepository.findById(botId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.not_found")); + BotUser botUser = botUserRepository.findById(botUserId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "bot.error.contact_not_found")); + + Lead lead = Lead.builder() + .name(name) + .email(email) + .phone(phone) + .data(data) + .bot(bot) + .botUser(botUser) + .build(); + + lead = leadRepository.save(lead); + log.info("Created lead for bot {} by bot user {}: name={}", botId, botUserId, name); + + final Lead savedLead = lead; + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + integrationEventService.onLeadCreated(savedLead); + } + }); + } else { + integrationEventService.onLeadCreated(savedLead); + } + + LeadResponse response = crmMapper.toLeadResponse(lead); + if (outboxService != null) { + outboxService.publish("CRM_LEAD", String.valueOf(lead.getId()), "LEAD_CREATED", response); + } + webSocketService.notifyNewLead(botId, response); + return response; + } + + @Override + @Transactional(readOnly = true) + public List getLeadsByBot(Long botId, Long userId) { + verifyBotOwnership(botId, userId); + List leads = leadRepository.findByBotIdOrderByCreatedAtDesc(botId); + return crmMapper.toLeadResponseList(leads); + } + + @Override + @Transactional + public LeadResponse updateLead(Long leadId, LeadUpdateRequest request, Long userId) { + Lead lead = leadRepository.findById(leadId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "crm.error.lead_not_found")); + + verifyBotOwnership(lead.getBot().getId(), userId); + + if (request.status() != null) { + lead.setStatus(request.status()); + } + if (request.notes() != null) { + lead.setNotes(request.notes()); + } + + lead = leadRepository.save(lead); + log.info("Updated lead {} status={}", lead.getId(), lead.getStatus()); + LeadResponse response = crmMapper.toLeadResponse(lead); + webSocketService.notifyLeadUpdate(lead.getBot().getId(), response); + return response; + } + + private void verifyBotOwnership(Long botId, Long userId) { + botRepository.findByIdAndUserId(botId, userId) + .orElseThrow(() -> new AppException(HttpStatus.FORBIDDEN, "bot.error.access_denied")); + } +} diff --git a/backend/src/main/java/com/launchly/crm/service/impl/CrmServiceImpl.java b/backend/src/main/java/com/launchly/crm/service/impl/CrmServiceImpl.java index 31737d75..d8414137 100644 --- a/backend/src/main/java/com/launchly/crm/service/impl/CrmServiceImpl.java +++ b/backend/src/main/java/com/launchly/crm/service/impl/CrmServiceImpl.java @@ -1,659 +1,133 @@ package com.launchly.crm.service.impl; -import com.launchly.bot.entity.Bot; -import com.launchly.bot.entity.BotUser; -import com.launchly.bot.repository.BotRepository; -import com.launchly.bot.repository.BotUserRepository; -import com.launchly.common.exception.AppException; +import com.launchly.crm.dto.request.AddNoteRequest; import com.launchly.crm.dto.request.ConversationUpdateRequest; import com.launchly.crm.dto.request.LeadUpdateRequest; import com.launchly.crm.dto.request.OrderUpdateRequest; -import com.launchly.crm.dto.request.AddNoteRequest; import com.launchly.crm.dto.request.SendMessageRequest; import com.launchly.crm.dto.response.ConversationResponse; import com.launchly.crm.dto.response.LeadResponse; import com.launchly.crm.dto.response.MessageResponse; import com.launchly.crm.dto.response.OrderResponse; -import com.launchly.crm.entity.Conversation; -import com.launchly.crm.entity.Lead; -import com.launchly.crm.entity.Message; -import com.launchly.common.outbox.OutboxService; -import com.launchly.crm.entity.Order; -import com.launchly.crm.entity.SenderType; -import com.launchly.crm.mapper.CrmMapper; -import com.launchly.crm.repository.ConversationRepository; -import com.launchly.integration.service.IntegrationEventService; -import com.launchly.crm.repository.LeadRepository; -import com.launchly.crm.repository.MessageRepository; -import com.launchly.crm.repository.OrderRepository; -import com.launchly.bot.service.TelegramSendService; -import com.launchly.bot.telegram.TelegramBotManager; -import com.launchly.common.utils.EncryptionUtil; +import com.launchly.crm.service.CrmConversationService; +import com.launchly.crm.service.CrmLabelService; +import com.launchly.crm.service.CrmMessageService; +import com.launchly.crm.service.CrmPipelineService; import com.launchly.crm.service.CrmService; -import com.launchly.crm.websocket.CrmWebSocketService; -import com.cloudinary.Cloudinary; -import com.launchly.auth.repository.UserRepository; -import com.launchly.crm.repository.CrmLabelRepository; -import com.launchly.notification.service.NotificationService; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.web.client.RestTemplate; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Comparator; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Lazy; -import org.springframework.http.HttpStatus; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; + import java.math.BigDecimal; import java.util.List; -@Slf4j @Service +@RequiredArgsConstructor public class CrmServiceImpl implements CrmService { - private final OrderRepository orderRepository; - private final LeadRepository leadRepository; - private final ConversationRepository conversationRepository; - private final MessageRepository messageRepository; - private final BotRepository botRepository; - private final BotUserRepository botUserRepository; - private final CrmMapper crmMapper; - private final CrmWebSocketService webSocketService; - private final TelegramSendService telegramSendService; - private final IntegrationEventService integrationEventService; - private final TelegramBotManager botManager; - private final EncryptionUtil encryptionUtil; - private final Cloudinary cloudinary; - private final NotificationService notificationService; - private final CrmLabelRepository crmLabelRepository; - private final UserRepository userRepository; - private final StringRedisTemplate stringRedisTemplate; - private final OutboxService outboxService; - - @Autowired - public CrmServiceImpl(OrderRepository orderRepository, - LeadRepository leadRepository, - ConversationRepository conversationRepository, - MessageRepository messageRepository, - BotRepository botRepository, - BotUserRepository botUserRepository, - CrmMapper crmMapper, - CrmWebSocketService webSocketService, - TelegramSendService telegramSendService, - IntegrationEventService integrationEventService, - @Lazy TelegramBotManager botManager, - EncryptionUtil encryptionUtil, - Cloudinary cloudinary, - NotificationService notificationService, - CrmLabelRepository crmLabelRepository, - UserRepository userRepository, - StringRedisTemplate stringRedisTemplate, - @Autowired(required = false) OutboxService outboxService) { - this.orderRepository = orderRepository; - this.leadRepository = leadRepository; - this.conversationRepository = conversationRepository; - this.messageRepository = messageRepository; - this.botRepository = botRepository; - this.botUserRepository = botUserRepository; - this.crmMapper = crmMapper; - this.webSocketService = webSocketService; - this.telegramSendService = telegramSendService; - this.integrationEventService = integrationEventService; - this.botManager = botManager; - this.encryptionUtil = encryptionUtil; - this.cloudinary = cloudinary; - this.notificationService = notificationService; - this.crmLabelRepository = crmLabelRepository; - this.userRepository = userRepository; - this.stringRedisTemplate = stringRedisTemplate; - this.outboxService = outboxService; - } + private final CrmPipelineService crmPipelineService; + private final CrmConversationService crmConversationService; + private final CrmMessageService crmMessageService; + private final CrmLabelService crmLabelService; @Override - @Transactional public OrderResponse createOrder(Long botId, Long botUserId, String items, BigDecimal totalAmount, String currency) { - Bot bot = botRepository.findById(botId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Bot not found")); - BotUser botUser = botUserRepository.findById(botUserId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Bot user not found")); - Long nextNumber = bot.getOrderSequence() + 1; - bot.setOrderSequence(nextNumber); - botRepository.save(bot); - String orderNumber = "#" + nextNumber; - Order order = Order.builder() - .orderNumber(orderNumber) - .items(items) - .totalAmount(totalAmount != null ? totalAmount : BigDecimal.ZERO) - .currency(currency != null ? currency : "UAH") - .bot(bot) - .botUser(botUser) - .build(); - - order = orderRepository.save(order); - log.info("Created order {} for bot {} by bot user {}", orderNumber, botId, botUserId); - - final Order savedOrder = order; - if (TransactionSynchronizationManager.isActualTransactionActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - integrationEventService.onOrderCreated(savedOrder); - } - }); - } else { - integrationEventService.onOrderCreated(savedOrder); - } - - OrderResponse response = crmMapper.toOrderResponse(order); - if (outboxService != null) { - outboxService.publish("CRM_ORDER", String.valueOf(order.getId()), "ORDER_CREATED", response); - } - webSocketService.notifyNewOrder(botId, response); - return response; + return crmPipelineService.createOrder(botId, botUserId, items, totalAmount, currency); } @Override - @Transactional(readOnly = true) public List getOrdersByBot(Long botId, Long userId) { - verifyBotOwnership(botId, userId); - List orders = orderRepository.findByBotIdOrderByCreatedAtDesc(botId); - return crmMapper.toOrderResponseList(orders); + return crmPipelineService.getOrdersByBot(botId, userId); } @Override - @Transactional public OrderResponse updateOrder(Long orderId, OrderUpdateRequest request, Long userId) { - Order order = orderRepository.findById(orderId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Order not found")); - - verifyBotOwnership(order.getBot().getId(), userId); - - if (request.status() != null) { - order.setStatus(request.status()); - } - if (request.notes() != null) { - order.setNotes(request.notes()); - } - - order = orderRepository.save(order); - log.info("Updated order {} status={}", order.getOrderNumber(), order.getStatus()); - OrderResponse response = crmMapper.toOrderResponse(order); - webSocketService.notifyOrderUpdate(order.getBot().getId(), response); - return response; + return crmPipelineService.updateOrder(orderId, request, userId); } @Override - @Transactional public LeadResponse createLead(Long botId, Long botUserId, String name, String email, String phone, String data) { - Bot bot = botRepository.findById(botId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Bot not found")); - BotUser botUser = botUserRepository.findById(botUserId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Bot user not found")); - - Lead lead = Lead.builder() - .name(name) - .email(email) - .phone(phone) - .data(data) - .bot(bot) - .botUser(botUser) - .build(); - - lead = leadRepository.save(lead); - log.info("Created lead for bot {} by bot user {}: name={}", botId, botUserId, name); - - final Lead savedLead = lead; - if (TransactionSynchronizationManager.isActualTransactionActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - integrationEventService.onLeadCreated(savedLead); - } - }); - } else { - integrationEventService.onLeadCreated(savedLead); - } - - LeadResponse response = crmMapper.toLeadResponse(lead); - if (outboxService != null) { - outboxService.publish("CRM_LEAD", String.valueOf(lead.getId()), "LEAD_CREATED", response); - } - webSocketService.notifyNewLead(botId, response); - return response; + return crmPipelineService.createLead(botId, botUserId, name, email, phone, data); } @Override - @Transactional(readOnly = true) public List getLeadsByBot(Long botId, Long userId) { - verifyBotOwnership(botId, userId); - List leads = leadRepository.findByBotIdOrderByCreatedAtDesc(botId); - return crmMapper.toLeadResponseList(leads); + return crmPipelineService.getLeadsByBot(botId, userId); } @Override - @Transactional public LeadResponse updateLead(Long leadId, LeadUpdateRequest request, Long userId) { - Lead lead = leadRepository.findById(leadId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Lead not found")); - - verifyBotOwnership(lead.getBot().getId(), userId); - - if (request.status() != null) { - lead.setStatus(request.status()); - } - if (request.notes() != null) { - lead.setNotes(request.notes()); - } - - lead = leadRepository.save(lead); - log.info("Updated lead {} status={}", lead.getId(), lead.getStatus()); - LeadResponse response = crmMapper.toLeadResponse(lead); - webSocketService.notifyLeadUpdate(lead.getBot().getId(), response); - return response; + return crmPipelineService.updateLead(leadId, request, userId); } - @Override - @Transactional public List getConversationsByBot(Long botId, Long userId) { - verifyBotOwnership(botId, userId); - List conversations = conversationRepository.findByBotIdOrderByUpdatedAtDesc(botId); - return conversations.stream() - .map(this::toConversationResponse) - .toList(); + return crmConversationService.getConversationsByBot(botId, userId); } @Override - @Transactional public List getAllConversations(Long userId) { - List conversations = conversationRepository.findByBotUserIdOrderByUpdatedAtDesc(userId); - return conversations.stream() - .map(this::toConversationResponse) - .toList(); + return crmConversationService.getAllConversations(userId); } @Override - @Transactional(readOnly = true) public List getMessages(Long conversationId, Long userId) { - Conversation conversation = conversationRepository.findById(conversationId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Conversation not found")); - verifyBotOwnership(conversation.getBot().getId(), userId); - List messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversationId); - return crmMapper.toMessageResponseList(messages); + return crmMessageService.getMessages(conversationId, userId); } @Override - @Transactional public MessageResponse saveIncomingMessage(Long botId, Long botUserId, String content) { - Conversation conversation = getOrCreateConversation(botId, botUserId); - conversation.setUnread(true); - conversation = conversationRepository.save(conversation); - - Message message = Message.builder() - .content(content) - .senderType(SenderType.BOT_USER) - .conversation(conversation) - .build(); - message = messageRepository.save(message); - MessageResponse response = crmMapper.toMessageResponse(message); - webSocketService.notifyNewMessage(botId, response); - - try { - com.launchly.auth.entity.User botOwner = conversation.getBot().getUser(); - if (botOwner != null) { - notificationService.sendNewMessageNotification(botOwner.getId(), conversation.getId(), content); - } - } catch (Exception e) { - log.error("Failed to send incoming message notification", e); - } - - return response; + return crmMessageService.saveIncomingMessage(botId, botUserId, content); } @Override - @Transactional public MessageResponse saveBotMessage(Long botId, Long botUserId, String content) { - return saveBotMessage(botId, botUserId, content, null, null); + return crmMessageService.saveBotMessage(botId, botUserId, content); } @Override - @Transactional public MessageResponse saveBotMessage(Long botId, Long botUserId, String content, String mediaUrl, String mediaType) { - Conversation conversation = getOrCreateConversation(botId, botUserId); - - Message message = Message.builder() - .content(content != null ? content : "") - .senderType(SenderType.OWNER) - .mediaUrl(mediaUrl) - .mediaType(mediaType) - .conversation(conversation) - .build(); - message = messageRepository.save(message); - MessageResponse response = crmMapper.toMessageResponse(message); - webSocketService.notifyNewMessage(botId, response); - return response; + return crmMessageService.saveBotMessage(botId, botUserId, content, mediaUrl, mediaType); } @Override - @Transactional public MessageResponse sendOwnerMessage(Long conversationId, SendMessageRequest request, Long userId) { - Conversation conversation = conversationRepository.findById(conversationId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Conversation not found")); - verifyBotOwnership(conversation.getBot().getId(), userId); - - Message.MessageBuilder builder = Message.builder() - .content(request.content()) - .senderType(SenderType.OWNER) - .conversation(conversation); - - if (request.mediaUrl() != null && !request.mediaUrl().isBlank()) { - builder.mediaUrl(request.mediaUrl()); - builder.mediaType(request.mediaType() != null ? request.mediaType() : "image"); - } - - if (request.scheduledAt() != null && request.scheduledAt().isAfter(LocalDateTime.now())) { - builder.scheduledAt(request.scheduledAt()); - builder.sent(false); - } else { - builder.sent(true); - } - - Message message = builder.build(); - message = messageRepository.save(message); - log.info("Owner saved message to conversation {} in DB (scheduled={})", conversationId, request.scheduledAt() != null); - - if (message.getSent()) { - if (request.mediaUrl() != null && !request.mediaUrl().isBlank()) { - telegramSendService.sendPhoto( - conversation.getBot().getId(), - conversation.getBotUser().getTelegramId(), - request.mediaUrl(), - request.content() - ); - } else { - telegramSendService.sendMessage( - conversation.getBot().getId(), - conversation.getBotUser().getTelegramId(), - request.content() - ); - } - } - - MessageResponse response = crmMapper.toMessageResponse(message); - webSocketService.notifyNewMessage(conversation.getBot().getId(), response); - return response; + return crmMessageService.sendOwnerMessage(conversationId, request, userId); } @Override - @Transactional public MessageResponse addNote(Long conversationId, AddNoteRequest request, Long userId) { - Conversation conversation = conversationRepository.findById(conversationId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Conversation not found")); - verifyBotOwnership(conversation.getBot().getId(), userId); - - Message message = Message.builder() - .content(request.content()) - .senderType(SenderType.NOTE) - .conversation(conversation) - .build(); - message = messageRepository.save(message); - log.info("Owner added note to conversation {} in DB", conversationId); - - MessageResponse response = crmMapper.toMessageResponse(message); - webSocketService.notifyNewMessage(conversation.getBot().getId(), response); - return response; - } - - - private Conversation getOrCreateConversation(Long botId, Long botUserId) { - return conversationRepository.findByBotIdAndBotUserId(botId, botUserId) - .orElseGet(() -> { - Bot bot = botRepository.findById(botId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Bot not found")); - BotUser botUser = botUserRepository.findById(botUserId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Bot user not found")); - - Conversation conversation = Conversation.builder() - .bot(bot) - .botUser(botUser) - .build(); - return conversationRepository.save(conversation); - }); - } - - private ConversationResponse toConversationResponse(Conversation conversation) { - BotUser botUser = conversation.getBotUser(); - if (botUser.getPhotoUrl() == null || botUser.getPhotoUrl().startsWith("https://api.telegram.org/")) { - fetchAndSetPhotoUrl(botUser); - } - String botUserName = botUser.getFirstName() + (botUser.getLastName() != null ? " " + botUser.getLastName() : ""); - - Message last = messageRepository.findFirstByConversationIdOrderByCreatedAtDesc(conversation.getId()).orElse(null); - String lastMessage = null; - LocalDateTime lastMessageAt = null; - if (last != null) { - lastMessage = last.getContent(); - lastMessageAt = last.getCreatedAt(); - } - - return new ConversationResponse( - conversation.getId(), - conversation.getStatus(), - conversation.isUnread(), - conversation.isFavorite(), - conversation.getTags() != null ? conversation.getTags() : List.of(), - conversation.getNotes(), - botUserName, - botUser.getUsername(), - botUser.getTelegramId(), - botUser.getPhotoUrl(), - lastMessage, - lastMessageAt, - conversation.getUpdatedAt(), - conversation.getBot().getId(), - conversation.getBot().getName() - ); - } - - private void fetchAndSetPhotoUrl(BotUser botUser) { - Long botId = botUser.getBot().getId(); - org.telegram.telegrambots.meta.generics.TelegramClient telegramClient = botManager.getTelegramClient(botId); - if (telegramClient == null) { - return; - } - try { - org.telegram.telegrambots.meta.api.methods.GetUserProfilePhotos getUserProfilePhotos = - org.telegram.telegrambots.meta.api.methods.GetUserProfilePhotos.builder() - .userId(botUser.getTelegramId()) - .limit(1) - .build(); - org.telegram.telegrambots.meta.api.objects.UserProfilePhotos photos = telegramClient.execute(getUserProfilePhotos); - if (photos != null && photos.getTotalCount() > 0 && photos.getPhotos() != null && !photos.getPhotos().isEmpty()) { - List photoSizes = photos.getPhotos().get(0); - org.telegram.telegrambots.meta.api.objects.PhotoSize largest = photoSizes.stream() - .max(Comparator.comparingInt(size -> size.getWidth() * size.getHeight())) - .orElse(null); - if (largest != null) { - org.telegram.telegrambots.meta.api.methods.GetFile getFile = - org.telegram.telegrambots.meta.api.methods.GetFile.builder() - .fileId(largest.getFileId()) - .build(); - org.telegram.telegrambots.meta.api.objects.File file = telegramClient.execute(getFile); - if (file != null && file.getFilePath() != null) { - Bot bot = botRepository.findById(botId).orElse(null); - if (bot == null) { - return; - } - String botToken = encryptionUtil.decrypt(bot.getTelegramToken()); - String fileUrl = "https://api.telegram.org/file/bot" + botToken + "/" + file.getFilePath(); - try { - RestTemplate restTemplate = new RestTemplate(); - byte[] fileBytes = restTemplate.getForObject(fileUrl, byte[].class); - if (fileBytes != null && fileBytes.length > 0) { - Map params = Map.of( - "folder", "launchly/" + bot.getUser().getId() + "/contacts", - "transformation", "c_limit,w_400,h_400,q_auto,f_auto" - ); - Map result = cloudinary.uploader().upload(fileBytes, params); - String secureUrl = (String) result.get("secure_url"); - botUser.setPhotoUrl(secureUrl); - } else { - botUser.setPhotoUrl(fileUrl); - } - } catch (Exception uploadEx) { - log.warn("Failed to upload profile photo to Cloudinary: {}", uploadEx.getMessage()); - botUser.setPhotoUrl(fileUrl); - } - botUserRepository.save(botUser); - log.debug("Fetched profile photo for user {}", botUser.getTelegramId()); - } - } - } - } catch (Exception e) { - log.warn("Could not fetch profile photo for user {}: {}", botUser.getTelegramId(), e.getMessage()); - } + return crmMessageService.addNote(conversationId, request, userId); } @Override - @Transactional(readOnly = true) public ConversationResponse getConversation(Long conversationId, Long userId) { - Conversation conversation = conversationRepository.findById(conversationId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Conversation not found")); - verifyBotOwnership(conversation.getBot().getId(), userId); - return toConversationResponse(conversation); + return crmConversationService.getConversation(conversationId, userId); } @Override - @Transactional public ConversationResponse updateConversation(Long conversationId, ConversationUpdateRequest request, Long userId) { - Conversation conversation = conversationRepository.findById(conversationId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Conversation not found")); - verifyBotOwnership(conversation.getBot().getId(), userId); - - if (request.status() != null) { - conversation.setStatus(request.status()); - } - if (request.unread() != null) { - conversation.setUnread(request.unread()); - } - if (request.favorite() != null) { - conversation.setFavorite(request.favorite()); - } - if (request.tags() != null) { - conversation.setTags(request.tags()); - } - if (request.notes() != null) { - conversation.setNotes(request.notes()); - } - - conversation = conversationRepository.save(conversation); - ConversationResponse response = toConversationResponse(conversation); - MessageResponse wsNotify = new MessageResponse( - -1L, - conversation.getId(), - "🖱️ status_updated", - SenderType.OWNER, - null, - null, - LocalDateTime.now(), - null, - true - ); - webSocketService.notifyNewMessage(conversation.getBot().getId(), wsNotify); - - return response; + return crmConversationService.updateConversation(conversationId, request, userId); } @Override - @Transactional(readOnly = true) public List getLabels(Long userId) { - return crmLabelRepository.findByUserId(userId).stream() - .map(com.launchly.crm.entity.CrmLabel::getName) - .toList(); + return crmLabelService.getLabels(userId); } @Override - @Transactional public List addLabel(String name, Long userId) { - if (name != null && !name.isBlank()) { - String trimmed = name.trim(); - if (crmLabelRepository.findByUserIdAndName(userId, trimmed).isEmpty()) { - com.launchly.auth.entity.User user = userRepository.findById(userId) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "User not found")); - crmLabelRepository.save(com.launchly.crm.entity.CrmLabel.builder() - .name(trimmed) - .user(user) - .build()); - } - } - return getLabels(userId); + return crmLabelService.addLabel(name, userId); } @Override - @Transactional public List deleteLabel(String name, Long userId) { - if (name != null && !name.isBlank()) { - crmLabelRepository.deleteByUserIdAndName(userId, name.trim()); - } - return getLabels(userId); - } - - private void verifyBotOwnership(Long botId, Long userId) { - botRepository.findByIdAndUserId(botId, userId) - .orElseThrow(() -> new AppException(HttpStatus.FORBIDDEN, "Access denied to this bot")); + return crmLabelService.deleteLabel(name, userId); } @Override - @Transactional public void sendScheduledMessages() { - List dueMessages = messageRepository.findBySentFalseAndScheduledAtBefore(LocalDateTime.now()); - if (dueMessages.isEmpty()) { - return; - } - - log.info("Found {} scheduled messages ready to send", dueMessages.size()); - - for (Message message : dueMessages) { - String lockKey = "lock:crm:scheduled:" + message.getId(); - Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(5)); - if (Boolean.FALSE.equals(acquired)) { - continue; - } - - try { - Conversation conversation = message.getConversation(); - - if (message.getMediaUrl() != null && !message.getMediaUrl().isBlank()) { - telegramSendService.sendPhoto( - conversation.getBot().getId(), - conversation.getBotUser().getTelegramId(), - message.getMediaUrl(), - message.getContent() - ); - } else { - telegramSendService.sendMessage( - conversation.getBot().getId(), - conversation.getBotUser().getTelegramId(), - message.getContent() - ); - } - - message.setSent(true); - messageRepository.save(message); - log.info("Dispatched scheduled message id={}", message.getId()); - - MessageResponse response = crmMapper.toMessageResponse(message); - webSocketService.notifyNewMessage(conversation.getBot().getId(), response); - } catch (Exception e) { - log.error("Failed to send scheduled message id={}: {}", message.getId(), e.getMessage(), e); - } - } + crmMessageService.sendScheduledMessages(); } } diff --git a/backend/src/main/java/com/launchly/integration/controller/HotmartWebhookController.java b/backend/src/main/java/com/launchly/integration/controller/HotmartWebhookController.java index 615e6f3b..2a16e399 100644 --- a/backend/src/main/java/com/launchly/integration/controller/HotmartWebhookController.java +++ b/backend/src/main/java/com/launchly/integration/controller/HotmartWebhookController.java @@ -1,5 +1,6 @@ package com.launchly.integration.controller; +import com.launchly.common.dto.SuccessResponse; import com.launchly.common.exception.ErrorResponse; import com.launchly.integration.service.HotmartService; import io.swagger.v3.oas.annotations.Operation; @@ -18,7 +19,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; @Tag(name = "Integration: Hotmart Webhooks", description = "Hotmart e-learning platform incoming purchase/refund event processing") @Slf4j @@ -35,13 +35,13 @@ public class HotmartWebhookController { @ApiResponse(responseCode = "400", description = "Invalid token or malformed payload", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @PostMapping("/webhook") - public ResponseEntity> handleWebhook( + public ResponseEntity handleWebhook( @Parameter(description = "Target Bot ID") @RequestParam(name = "botId") Long botId, @Parameter(description = "Hotmart security token header") @RequestHeader(name = "X-Hotmart-Hottok", required = false) String hottokHeader, @RequestBody String rawPayload ) { hotmartService.processWebhook(botId, hottokHeader, rawPayload); - return ResponseEntity.ok(Map.of("status", "ok", "message", "Webhook processed successfully")); + return ResponseEntity.ok(SuccessResponse.ok("Webhook processed successfully")); } } diff --git a/backend/src/main/java/com/launchly/integration/controller/IntegrationController.java b/backend/src/main/java/com/launchly/integration/controller/IntegrationController.java index 00fd354d..b1982efa 100644 --- a/backend/src/main/java/com/launchly/integration/controller/IntegrationController.java +++ b/backend/src/main/java/com/launchly/integration/controller/IntegrationController.java @@ -37,6 +37,7 @@ import org.springframework.web.bind.annotation.RestController; import java.io.IOException; import java.util.List; +import java.util.Map; @Tag(name = "Integration: Third-Party Services", description = "Google Sheets OAuth/Spreadsheets, Webhooks, Excel Exports, Mailchimp, and Hotmart") @RestController @@ -128,6 +129,15 @@ public ResponseEntity exportExcel( .body(result.data()); } + @Operation(summary = "Get Google OAuth authorization URL", description = "Returns Google OAuth authorization URL for the authenticated user without token query param.") + @GetMapping("/google/auth-url") + public ResponseEntity> getGoogleAuthUrl( + @Parameter(description = "Target Bot ID") @RequestParam Long botId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String authUrl = googleSheetsService.buildAuthorizationUrl(botId, userDetails.getId()); + return ResponseEntity.ok(Map.of("url", authUrl)); + } + @Operation(summary = "Initiate Google OAuth flow", description = "Redirect to Google authorization screen to authorize Google Sheets API integration.") @GetMapping("/google/auth") public void googleAuth( @@ -153,7 +163,7 @@ public void googleCallback( @ApiResponse(responseCode = "200", description = "List of spreadsheets (id and title)") }) @GetMapping("/google/spreadsheets") - public ResponseEntity>> getSpreadsheets( + public ResponseEntity>> getSpreadsheets( @Parameter(description = "Bot ID") @RequestParam Long botId, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(googleSheetsService.getSpreadsheets(botId)); diff --git a/backend/src/main/java/com/launchly/integration/entity/Integration.java b/backend/src/main/java/com/launchly/integration/entity/Integration.java index c617cf0f..f6ac8529 100644 --- a/backend/src/main/java/com/launchly/integration/entity/Integration.java +++ b/backend/src/main/java/com/launchly/integration/entity/Integration.java @@ -13,9 +13,11 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; import java.time.LocalDateTime; @@ -26,8 +28,10 @@ @Index(name = "idx_integrations_type", columnList = "type"), @Index(name = "idx_integrations_active", columnList = "bot_id, is_active") }) -@Data -@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString(exclude = {"bot"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/launchly/integration/repository/IntegrationRepository.java b/backend/src/main/java/com/launchly/integration/repository/IntegrationRepository.java index 316f9cb6..7248475d 100644 --- a/backend/src/main/java/com/launchly/integration/repository/IntegrationRepository.java +++ b/backend/src/main/java/com/launchly/integration/repository/IntegrationRepository.java @@ -4,6 +4,9 @@ import com.launchly.integration.entity.IntegrationType; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -21,6 +24,12 @@ public interface IntegrationRepository extends JpaRepository @EntityGraph(attributePaths = {"bot"}) Optional findByBotIdAndType(Long botId, IntegrationType type); + @Query("SELECT i.type, COUNT(i) FROM Integration i GROUP BY i.type") + List countGroupedByType(); + + @Query("SELECT i.type, COUNT(i) FROM Integration i WHERE i.createdAt < :date GROUP BY i.type") + List countGroupedByTypeAndCreatedAtBefore(@Param("date") LocalDateTime date); + @Override @EntityGraph(attributePaths = {"bot"}) Optional findById(Long id); diff --git a/backend/src/main/java/com/launchly/integration/service/impl/ExcelExportServiceImpl.java b/backend/src/main/java/com/launchly/integration/service/impl/ExcelExportServiceImpl.java index 193f863f..86a2306a 100644 --- a/backend/src/main/java/com/launchly/integration/service/impl/ExcelExportServiceImpl.java +++ b/backend/src/main/java/com/launchly/integration/service/impl/ExcelExportServiceImpl.java @@ -82,12 +82,7 @@ private byte[] exportOrders(Long botId, Long userId) { row.createCell(6).setCellValue(order.getItems() != null ? order.getItems() : ""); row.createCell(7).setCellValue(order.getBotUser() != null && order.getBotUser().getTelegramId() != null ? order.getBotUser().getTelegramId() : 0L); - String customerName = ""; - if (order.getBotUser() != null) { - String firstName = order.getBotUser().getFirstName() != null ? order.getBotUser().getFirstName() : ""; - String lastName = order.getBotUser().getLastName() != null ? order.getBotUser().getLastName() : ""; - customerName = (firstName + " " + lastName).trim(); - } + String customerName = order.getBotUser() != null ? order.getBotUser().getDisplayName() : ""; row.createCell(8).setCellValue(customerName); row.createCell(9).setCellValue(order.getCreatedAt() != null ? order.getCreatedAt().format(DATE_FORMATTER) : ""); } diff --git a/backend/src/main/java/com/launchly/integration/service/impl/GoogleSheetsServiceImpl.java b/backend/src/main/java/com/launchly/integration/service/impl/GoogleSheetsServiceImpl.java index 27df9a3b..1d4789c7 100644 --- a/backend/src/main/java/com/launchly/integration/service/impl/GoogleSheetsServiceImpl.java +++ b/backend/src/main/java/com/launchly/integration/service/impl/GoogleSheetsServiceImpl.java @@ -35,6 +35,14 @@ @RequiredArgsConstructor public class GoogleSheetsServiceImpl implements GoogleSheetsService { + private static final String GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String GOOGLE_SHEETS_API_BASE = "https://sheets.googleapis.com/v4/spreadsheets/"; + private static final String GOOGLE_DRIVE_API_FILES = "https://www.googleapis.com/drive/v3/files"; + private static final String GOOGLE_OAUTH_SCOPES = "openid email profile https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/userinfo.email"; + private static final long AUTH_STATE_EXPIRATION_MS = 300_000L; + private static final long DEFAULT_TOKEN_EXPIRES_IN = 3599L; + private final IntegrationRepository integrationRepository; private final BotRepository botRepository; private final EncryptionUtil encryptionUtil; @@ -63,22 +71,21 @@ public String buildAuthorizationUrl(Long botId, Long userId) { .claim("botId", botId) .claim("userId", userId) .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + 300000)) + .expiration(new Date(System.currentTimeMillis() + AUTH_STATE_EXPIRATION_MS)) .signWith(getSigningKey()) .compact(); - return "https://accounts.google.com/o/oauth2/v2/auth" + + return GOOGLE_AUTH_URL + "?client_id=" + URLEncoder.encode(googleClientId, StandardCharsets.UTF_8) + "&redirect_uri=" + URLEncoder.encode(googleRedirectUri, StandardCharsets.UTF_8) + "&response_type=code" + - "&scope=" + URLEncoder.encode("openid email profile https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/userinfo.email", StandardCharsets.UTF_8) + + "&scope=" + URLEncoder.encode(GOOGLE_OAUTH_SCOPES, StandardCharsets.UTF_8) + "&access_type=offline" + "&prompt=consent" + "&state=" + URLEncoder.encode(stateToken, StandardCharsets.UTF_8); } @Override - @Transactional public Long authenticate(String stateToken, String code) { Long botId; Long userId; @@ -106,14 +113,14 @@ public Long authenticate(String stateToken, String code) { "&grant_type=authorization_code"; HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("https://oauth2.googleapis.com/token")) + .uri(URI.create(GOOGLE_TOKEN_URL)) .header("Content-Type", "application/x-www-form-urlencoded") .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed Google OAuth token exchange. Status: {}, Body: {}", response.statusCode(), response.body()); throw new AppException(HttpStatus.BAD_REQUEST, "integration.error.google_auth_failed"); } @@ -122,7 +129,7 @@ public Long authenticate(String stateToken, String code) { String accessToken = tokenResponse.path("access_token").asText(); String refreshToken = tokenResponse.path("refresh_token").asText(null); String idToken = tokenResponse.path("id_token").asText(null); - long expiresIn = tokenResponse.path("expires_in").asLong(3599); + long expiresIn = tokenResponse.path("expires_in").asLong(DEFAULT_TOKEN_EXPIRES_IN); String email = extractEmailFromIdToken(idToken); Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS) @@ -195,7 +202,6 @@ private void copyConfigValue(String configJson, Map targetMap, S } @Override - @Transactional public void refreshTokenIfNeeded(Integration integration) { if (integration.getGoogleAccessToken() == null || integration.getGoogleRefreshToken() == null) { return; @@ -214,21 +220,21 @@ public void refreshTokenIfNeeded(Integration integration) { "&grant_type=refresh_token"; HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("https://oauth2.googleapis.com/token")) + .uri(URI.create(GOOGLE_TOKEN_URL)) .header("Content-Type", "application/x-www-form-urlencoded") .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed to refresh Google token. Status: {}, Body: {}", response.statusCode(), response.body()); throw new AppException(HttpStatus.BAD_REQUEST, "integration.error.google_refresh_failed"); } JsonNode tokenResponse = objectMapper.readTree(response.body()); String accessToken = tokenResponse.path("access_token").asText(); - long expiresIn = tokenResponse.path("expires_in").asLong(3599); + long expiresIn = tokenResponse.path("expires_in").asLong(DEFAULT_TOKEN_EXPIRES_IN); String newRefreshToken = tokenResponse.path("refresh_token").asText(null); integration.setGoogleAccessToken(encryptionUtil.encrypt(accessToken)); @@ -293,7 +299,7 @@ public void appendRow(Integration integration, String spreadsheetId, String shee String requestBody = objectMapper.writeValueAsString(bodyMap); String encodedSheetName = URLEncoder.encode(activeSheetName, StandardCharsets.UTF_8); - String url = "https://sheets.googleapis.com/v4/spreadsheets/" + activeSpreadsheetId + + String url = GOOGLE_SHEETS_API_BASE + activeSpreadsheetId + "/values/" + encodedSheetName + ":append?valueInputOption=USER_ENTERED"; HttpRequest request = HttpRequest.newBuilder() @@ -305,7 +311,7 @@ public void appendRow(Integration integration, String spreadsheetId, String shee HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed to append row to Google Sheets. Status: {}, Body: {}", response.statusCode(), response.body()); throw new AppException(HttpStatus.BAD_REQUEST, "integration.error.google_append_failed"); } else { @@ -321,7 +327,6 @@ public void appendRow(Integration integration, String spreadsheetId, String shee } @Override - @Transactional @CircuitBreaker(name = "googleSheets", fallbackMethod = "getSpreadsheetsFallback") @Retry(name = "googleSheets") public List> getSpreadsheets(Long botId) { @@ -331,7 +336,7 @@ public List> getSpreadsheets(Long botId) { try { String decryptedAccessToken = encryptionUtil.decrypt(integration.getGoogleAccessToken()); - String url = "https://www.googleapis.com/drive/v3/files" + + String url = GOOGLE_DRIVE_API_FILES + "?q=mimeType%3D%27application%2Fvnd.google-apps.spreadsheet%27%20and%20trashed%3Dfalse" + "&pageSize=100" + "&fields=files(id%2Cname)"; @@ -342,12 +347,12 @@ public List> getSpreadsheets(Long botId) { .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed to fetch spreadsheets from Google Drive. Status: {}, Body: {}", response.statusCode(), response.body()); if (response.body().contains("\"reason\": \"SERVICE_DISABLED\"")) { throw new AppException(HttpStatus.BAD_REQUEST, "integration.error.google_drive_enable"); } - if (response.statusCode() == 401 || response.statusCode() == 403) { + if (response.statusCode() == HttpStatus.UNAUTHORIZED.value() || response.statusCode() == HttpStatus.FORBIDDEN.value()) { throw new AppException(HttpStatus.BAD_REQUEST, "integration.error.google_drive_grant"); } throw new AppException(HttpStatus.BAD_REQUEST, "integration.error.google_load_failed"); @@ -379,17 +384,16 @@ public List> getSpreadsheets(Long botId) { } @Override - @Transactional @CircuitBreaker(name = "googleSheets", fallbackMethod = "getWorksheetsFallback") @Retry(name = "googleSheets") public List getWorksheets(Long botId, String spreadsheetId) { Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Google Sheets integration not connected")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "integration.error.google_sheets_not_connected")); refreshTokenIfNeeded(integration); try { String decryptedAccessToken = encryptionUtil.decrypt(integration.getGoogleAccessToken()); - String url = "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheetId; + String url = GOOGLE_SHEETS_API_BASE + spreadsheetId; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Authorization", "Bearer " + decryptedAccessToken) @@ -397,7 +401,7 @@ public List getWorksheets(Long botId, String spreadsheetId) { .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed to fetch sheets metadata. Status: {}, Body: {}", response.statusCode(), response.body()); return List.of(); } @@ -430,13 +434,13 @@ public List getWorksheets(Long botId, String spreadsheetId) { @Retry(name = "googleSheets") public List getHeaders(Long botId, String spreadsheetId, String worksheetName) { Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Google Sheets integration not connected")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "integration.error.google_sheets_not_connected")); refreshTokenIfNeeded(integration); try { String decryptedAccessToken = encryptionUtil.decrypt(integration.getGoogleAccessToken()); String encodedSheetName = URLEncoder.encode(worksheetName, StandardCharsets.UTF_8); - String url = "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheetId + "/values/" + encodedSheetName + "!1:1"; + String url = GOOGLE_SHEETS_API_BASE + spreadsheetId + "/values/" + encodedSheetName + "!1:1"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Authorization", "Bearer " + decryptedAccessToken) @@ -444,7 +448,7 @@ public List getHeaders(Long botId, String spreadsheetId, String workshee .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed to fetch sheet headers. Status: {}, Body: {}", response.statusCode(), response.body()); return List.of(); } @@ -455,8 +459,8 @@ public List getHeaders(Long botId, String spreadsheetId, String workshee if (valuesNode.isArray() && valuesNode.size() > 0) { JsonNode firstRow = valuesNode.get(0); if (firstRow.isArray()) { - for (JsonNode cell : firstRow) { - headers.add(cell.asText()); + for (JsonNode headerCell : firstRow) { + headers.add(headerCell.asText()); } } } @@ -477,13 +481,13 @@ public List getHeaders(Long botId, String spreadsheetId, String workshee @Retry(name = "googleSheets") public List> getSheetValues(Long botId, String spreadsheetId, String worksheetName) { Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Google Sheets integration not connected")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "integration.error.google_sheets_not_connected")); refreshTokenIfNeeded(integration); try { String decryptedAccessToken = encryptionUtil.decrypt(integration.getGoogleAccessToken()); String encodedSheetName = URLEncoder.encode(worksheetName, StandardCharsets.UTF_8); - String url = "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheetId + "/values/" + encodedSheetName + "!A:Z"; + String url = GOOGLE_SHEETS_API_BASE + spreadsheetId + "/values/" + encodedSheetName + "!A:Z"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Authorization", "Bearer " + decryptedAccessToken) @@ -491,7 +495,7 @@ public List> getSheetValues(Long botId, String spreadsheetId, Strin .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed to fetch sheet values. Status: {}, Body: {}", response.statusCode(), response.body()); return List.of(); } @@ -527,14 +531,14 @@ public List> getSheetValues(Long botId, String spreadsheetId, Strin @Retry(name = "googleSheets") public void updateCell(Long botId, String spreadsheetId, String worksheetName, String cellReference, Object value) { Integration integration = integrationRepository.findByBotIdAndType(botId, IntegrationType.GOOGLE_SHEETS) - .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "Google Sheets integration not connected")); + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "integration.error.google_sheets_not_connected")); refreshTokenIfNeeded(integration); try { String decryptedAccessToken = encryptionUtil.decrypt(integration.getGoogleAccessToken()); String fullRange = worksheetName + "!" + cellReference; String encodedRange = URLEncoder.encode(fullRange, StandardCharsets.UTF_8); - String url = "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheetId + + String url = GOOGLE_SHEETS_API_BASE + spreadsheetId + "/values/" + encodedRange + "?valueInputOption=USER_ENTERED"; Map bodyMap = new HashMap<>(); @@ -552,7 +556,7 @@ public void updateCell(Long botId, String spreadsheetId, String worksheetName, S .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() != HttpStatus.OK.value()) { log.error("Failed to update cell {}. Status: {}, Body: {}", fullRange, response.statusCode(), response.body()); } } catch (InterruptedException e) { diff --git a/backend/src/main/java/com/launchly/integration/service/impl/HotmartServiceImpl.java b/backend/src/main/java/com/launchly/integration/service/impl/HotmartServiceImpl.java index 0770a9e1..42b3d664 100644 --- a/backend/src/main/java/com/launchly/integration/service/impl/HotmartServiceImpl.java +++ b/backend/src/main/java/com/launchly/integration/service/impl/HotmartServiceImpl.java @@ -25,6 +25,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; @Slf4j @Service @@ -73,7 +75,12 @@ public void processWebhook(Long botId, String tokenHeader, String rawPayload) { String payloadToken = root.path("hottok").asText(null); String providedToken = (tokenHeader != null && !tokenHeader.trim().isEmpty()) ? tokenHeader.trim() : payloadToken; - if (config.hottok() == null || !config.hottok().trim().equalsIgnoreCase(providedToken != null ? providedToken.trim() : "")) { + String expectedToken = config.hottok() != null ? config.hottok().trim().toLowerCase() : ""; + String actualToken = providedToken != null ? providedToken.trim().toLowerCase() : ""; + + if (expectedToken.isEmpty() || !MessageDigest.isEqual( + expectedToken.getBytes(StandardCharsets.UTF_8), + actualToken.getBytes(StandardCharsets.UTF_8))) { log.warn("Unauthorized Hotmart webhook attempt for bot {}. Expected token mismatch.", botId); throw new AppException(HttpStatus.UNAUTHORIZED, "integration.error.hotmart_invalid_token"); } diff --git a/backend/src/main/java/com/launchly/integration/service/impl/IntegrationEventServiceImpl.java b/backend/src/main/java/com/launchly/integration/service/impl/IntegrationEventServiceImpl.java index 6744f1f9..c60e117a 100644 --- a/backend/src/main/java/com/launchly/integration/service/impl/IntegrationEventServiceImpl.java +++ b/backend/src/main/java/com/launchly/integration/service/impl/IntegrationEventServiceImpl.java @@ -11,6 +11,7 @@ import com.launchly.integration.repository.IntegrationRepository; import com.launchly.integration.service.GoogleSheetsService; import com.launchly.integration.service.IntegrationEventService; +import com.launchly.integration.service.MailchimpService; import com.launchly.integration.dto.request.WebhookConfig; import com.launchly.integration.service.WebhookService; import lombok.RequiredArgsConstructor; @@ -33,7 +34,7 @@ public class IntegrationEventServiceImpl implements IntegrationEventService { private final LeadRepository leadRepository; private final GoogleSheetsService googleSheetsService; private final WebhookService webhookService; - private final com.launchly.integration.service.MailchimpService mailchimpService; + private final MailchimpService mailchimpService; private final ObjectMapper objectMapper; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -132,12 +133,7 @@ private void processGoogleSheetsOrder(Integration integration, Order order) thro String dataType = configNode.path("dataType").asText(null); if ("ORDERS".equalsIgnoreCase(dataType)) { - String customerName = ""; - if (order.getBotUser() != null) { - String firstName = order.getBotUser().getFirstName() != null ? order.getBotUser().getFirstName() : ""; - String lastName = order.getBotUser().getLastName() != null ? order.getBotUser().getLastName() : ""; - customerName = (firstName + " " + lastName).trim(); - } + String customerName = order.getBotUser() != null ? order.getBotUser().getDisplayName() : ""; List row = List.of( order.getId() != null ? order.getId() : 0L, diff --git a/backend/src/main/java/com/launchly/integration/service/impl/MailchimpServiceImpl.java b/backend/src/main/java/com/launchly/integration/service/impl/MailchimpServiceImpl.java index fc98d626..bea8d80e 100644 --- a/backend/src/main/java/com/launchly/integration/service/impl/MailchimpServiceImpl.java +++ b/backend/src/main/java/com/launchly/integration/service/impl/MailchimpServiceImpl.java @@ -14,6 +14,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; import java.util.List; @@ -24,6 +25,9 @@ @RequiredArgsConstructor public class MailchimpServiceImpl implements MailchimpService { + private static final String MAILCHIMP_API_URL_TEMPLATE = "https://%s.api.mailchimp.com/3.0/lists/%s/members/%s"; + private static final int MD5_HEX_LENGTH = 32; + private final HttpClient httpClient; private final ObjectMapper objectMapper; @@ -40,7 +44,7 @@ public void addOrUpdateSubscriber(Integration integration, String email, String if (combinedTags == null) { combinedTags = config.tags(); } else { - combinedTags = new java.util.ArrayList<>(combinedTags); + combinedTags = new ArrayList<>(combinedTags); for (String t : config.tags()) { if (!combinedTags.contains(t)) { combinedTags.add(t); @@ -65,7 +69,7 @@ public void addOrUpdateSubscriber(String apiKey, String listId, String serverPre String dc = resolveDataCenter(apiKey, serverPrefix); String md5Hash = calculateMd5(trimmedEmail); - String url = String.format("https://%s.api.mailchimp.com/3.0/lists/%s/members/%s", dc, listId.trim(), md5Hash); + String url = String.format(MAILCHIMP_API_URL_TEMPLATE, dc, listId.trim(), md5Hash); try { Map body = new HashMap<>(); @@ -128,19 +132,19 @@ private String resolveDataCenter(String apiKey, String serverPrefix) { return "us1"; } - @SuppressWarnings("java:S4790") // Mailchimp API protocol explicitly mandates MD5 email hashing + @SuppressWarnings("java:S4790") private String calculateMd5(String input) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] messageDigest = md.digest(input.getBytes(StandardCharsets.UTF_8)); BigInteger no = new BigInteger(1, messageDigest); StringBuilder hashtext = new StringBuilder(no.toString(16)); - while (hashtext.length() < 32) { + while (hashtext.length() < MD5_HEX_LENGTH) { hashtext.insert(0, "0"); } return hashtext.toString(); } catch (Exception e) { - return input; + throw new RuntimeException("MD5 hashing failed", e); } } } diff --git a/backend/src/main/java/com/launchly/integration/service/impl/WebhookServiceImpl.java b/backend/src/main/java/com/launchly/integration/service/impl/WebhookServiceImpl.java index c5f9591b..649fa76c 100644 --- a/backend/src/main/java/com/launchly/integration/service/impl/WebhookServiceImpl.java +++ b/backend/src/main/java/com/launchly/integration/service/impl/WebhookServiceImpl.java @@ -17,6 +17,9 @@ @RequiredArgsConstructor public class WebhookServiceImpl implements WebhookService { + private static final int MAX_DISPATCH_ATTEMPTS = 4; + private static final long RETRY_DELAY_MS = 2000L; + private final HttpClient httpClient; @Override @@ -30,8 +33,7 @@ public void send(String url, String secret, String payload) { .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); - int maxAttempts = 4; - for (int attempt = 1; attempt <= maxAttempts; attempt++) { + for (int attempt = 1; attempt <= MAX_DISPATCH_ATTEMPTS; attempt++) { try { log.info("Sending webhook to {} (attempt {})", url, attempt); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); @@ -49,9 +51,9 @@ public void send(String url, String secret, String payload) { log.warn("Webhook attempt {} failed with exception: {}", attempt, e.getMessage()); } - if (attempt < maxAttempts) { + if (attempt < MAX_DISPATCH_ATTEMPTS) { try { - Thread.sleep(2000); + Thread.sleep(RETRY_DELAY_MS); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); log.error("Webhook dispatch retry interrupted for {}", url); @@ -60,7 +62,7 @@ public void send(String url, String secret, String payload) { } } - log.error("Failed to deliver webhook to {} after {} attempts", url, maxAttempts); + log.error("Failed to deliver webhook to {} after {} attempts", url, MAX_DISPATCH_ATTEMPTS); } private String calculateHmac(String payload, String secret) { diff --git a/backend/src/main/java/com/launchly/notification/controller/NotificationController.java b/backend/src/main/java/com/launchly/notification/controller/NotificationController.java index f9ffa7d5..c42fe457 100644 --- a/backend/src/main/java/com/launchly/notification/controller/NotificationController.java +++ b/backend/src/main/java/com/launchly/notification/controller/NotificationController.java @@ -3,6 +3,7 @@ import com.launchly.auth.dto.response.UserResponse; import com.launchly.common.exception.ErrorResponse; import com.launchly.notification.dto.UpdateNotificationSettingsRequest; +import com.launchly.notification.dto.request.UpdateTimezoneRequest; import com.launchly.notification.service.NotificationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -55,10 +56,9 @@ public ResponseEntity unlinkTelegram(Authentication authentication }) @PutMapping("/timezone") public ResponseEntity updateTimezone( - @RequestBody Map body, + @Valid @RequestBody UpdateTimezoneRequest request, Authentication authentication) { - String timezone = body.get("timezone"); - return ResponseEntity.ok(notificationService.updateTimezone(authentication.getName(), timezone)); + return ResponseEntity.ok(notificationService.updateTimezone(authentication.getName(), request.timezone())); } } diff --git a/backend/src/main/java/com/launchly/notification/dto/request/UpdateTimezoneRequest.java b/backend/src/main/java/com/launchly/notification/dto/request/UpdateTimezoneRequest.java new file mode 100644 index 00000000..a62a3882 --- /dev/null +++ b/backend/src/main/java/com/launchly/notification/dto/request/UpdateTimezoneRequest.java @@ -0,0 +1,13 @@ +package com.launchly.notification.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request payload to update user preferred timezone") +public record UpdateTimezoneRequest( + @Schema(description = "IANA Timezone identifier", example = "Europe/Kyiv", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "validation.notification.timezone.required") + @Size(max = 100, message = "validation.notification.timezone.size") + String timezone +) {} diff --git a/backend/src/main/java/com/launchly/notification/service/impl/NotificationServiceImpl.java b/backend/src/main/java/com/launchly/notification/service/impl/NotificationServiceImpl.java index aa99666b..46284083 100644 --- a/backend/src/main/java/com/launchly/notification/service/impl/NotificationServiceImpl.java +++ b/backend/src/main/java/com/launchly/notification/service/impl/NotificationServiceImpl.java @@ -30,11 +30,21 @@ import java.util.ArrayList; import java.util.List; +import com.launchly.bot.constant.BotConstants; +import com.launchly.bot.constant.TelegramConstants; + @Slf4j @Service @RequiredArgsConstructor public class NotificationServiceImpl implements NotificationService { + private static final int MAX_MESSAGE_PREVIEW_LENGTH = 150; + private static final int TRUNCATED_PREVIEW_LENGTH = 147; + private static final int TOP_BUTTONS_LIMIT = 5; + private static final String LOCALHOST = "localhost"; + private static final String LOOPBACK_IP = "127.0.0.1"; + private static final String DEV_TUNNEL_DOMAIN = "lvh.me"; + private final ObjectProvider mailSenderProvider; private final ObjectProvider botManagerProvider; private final UserQueryService userQueryService; @@ -83,7 +93,6 @@ public UserResponse updateTimezone(String email, String timezone) { @Override @Async - @Transactional(readOnly = true) public void sendAssignmentNotification(Long userId, Long botUserId) { User user = userQueryService.findById(userId).orElse(null); BotUser botUser = botUserRepository.findById(botUserId).orElse(null); @@ -92,11 +101,7 @@ public void sendAssignmentNotification(Long userId, Long botUserId) { return; } - String contactName = (botUser.getFirstName() != null ? botUser.getFirstName() : "") + - (botUser.getLastName() != null ? " " + botUser.getLastName() : ""); - if (contactName.trim().isEmpty()) { - contactName = botUser.getUsername() != null ? "@" + botUser.getUsername() : "Contact ID " + botUser.getId(); - } + String contactName = botUser.getDisplayName(); String message = String.format("A new contact (%s) has performed a specific action in your bot '%s' and requires attention.", contactName, botUser.getBot().getName()); @@ -135,7 +140,7 @@ public void sendAssignmentNotification(Long userId, Long botUserId) { if (user.isNotifyTelegram() && user.getTelegramUserId() != null) { TelegramBotManager botManager = botManagerProvider.getIfAvailable(); - TelegramClient systemBotClient = botManager != null ? botManager.getTelegramClient(-1L) : null; + TelegramClient systemBotClient = botManager != null ? botManager.getTelegramClient(BotConstants.SYSTEM_BOT_ID) : null; if (systemBotClient != null) { try { SendMessage sendMessage = SendMessage.builder() @@ -155,7 +160,6 @@ public void sendAssignmentNotification(Long userId, Long botUserId) { @Override @Async - @Transactional(readOnly = true) public void sendNewMessageNotification(Long userId, Long conversationId, String messageContent) { User user = userQueryService.findById(userId).orElse(null); Conversation conversation = conversationRepository.findById(conversationId).orElse(null); @@ -172,12 +176,12 @@ public void sendNewMessageNotification(Long userId, Long conversationId, String } String contactMention = botUser.getUsername() != null - ? String.format("%s (@%s)", botUser.getUsername(), contactName, botUser.getUsername()) - : String.format("%s", botUser.getTelegramId(), contactName); + ? String.format("%s (@%s)", botUser.getUsername(), contactName, botUser.getUsername()) + : String.format("%s", botUser.getTelegramId(), contactName); String messageText = messageContent; - if (messageText != null && messageText.length() > 150) { - messageText = messageText.substring(0, 147) + "..."; + if (messageText != null && messageText.length() > MAX_MESSAGE_PREVIEW_LENGTH) { + messageText = messageText.substring(0, TRUNCATED_PREVIEW_LENGTH) + "..."; } String convUrl = String.format("%s/chat?conversationId=%d", frontendUrl, conversation.getId()); @@ -231,26 +235,22 @@ public void sendNewMessageNotification(Long userId, Long conversationId, String if (user.isNotifyTelegram() && user.getTelegramUserId() != null) { TelegramBotManager botManager = botManagerProvider.getIfAvailable(); - TelegramClient systemBotClient = botManager != null ? botManager.getTelegramClient(-1L) : null; + TelegramClient systemBotClient = botManager != null ? botManager.getTelegramClient(BotConstants.SYSTEM_BOT_ID) : null; if (systemBotClient != null) { try { List keyboard = new ArrayList<>(); InlineKeyboardRow row = new InlineKeyboardRow(); String profileUrl = botUser.getUsername() != null - ? "https://t.me/" + botUser.getUsername() - : "tg://user?id=" + botUser.getTelegramId(); + ? TelegramConstants.TELEGRAM_DEEP_LINK + botUser.getUsername() + : TelegramConstants.TELEGRAM_USER_LINK + botUser.getTelegramId(); row.add(InlineKeyboardButton.builder() .text("👤 View Profile") .url(profileUrl) .build()); - String telegramUrl = convUrl; - if (telegramUrl.contains("localhost") || telegramUrl.contains("127.0.0.1")) { - telegramUrl = telegramUrl.replace("localhost", "lvh.me") - .replace("127.0.0.1", "lvh.me"); - } + String telegramUrl = toTelegramCompatibleUrl(convUrl); row.add(InlineKeyboardButton.builder() .text("💬 Open Conversation") @@ -298,7 +298,7 @@ public void sendStatsReportNotification(User user, DashboardStatsResponse stats) topButtonsHtml.append(" "); topButtonsHtml.append(" "); topButtonsHtml.append(" "); - for (DashboardStatsResponse.ButtonStatsEntry entry : stats.topButtons().stream().limit(5).toList()) { + for (DashboardStatsResponse.ButtonStatsEntry entry : stats.topButtons().stream().limit(TOP_BUTTONS_LIMIT).toList()) { topButtonsHtml.append(" "); topButtonsHtml.append(String.format(" %s", entry.buttonName())); topButtonsHtml.append(String.format(" %d", entry.clicks())); @@ -313,7 +313,7 @@ public void sendStatsReportNotification(User user, DashboardStatsResponse stats) topButtonsTg.append("No button clicks logged."); } else { int count = 1; - for (DashboardStatsResponse.ButtonStatsEntry entry : stats.topButtons().stream().limit(5).toList()) { + for (DashboardStatsResponse.ButtonStatsEntry entry : stats.topButtons().stream().limit(TOP_BUTTONS_LIMIT).toList()) { topButtonsTg.append(String.format("%d. %s — %d clicks\n", count++, entry.buttonName(), entry.clicks())); } } @@ -381,7 +381,7 @@ public void sendStatsReportNotification(User user, DashboardStatsResponse stats) if (user.isStatsNotifyTelegram() && user.getTelegramUserId() != null) { TelegramBotManager botManager = botManagerProvider.getIfAvailable(); - TelegramClient systemBotClient = botManager != null ? botManager.getTelegramClient(-1L) : null; + TelegramClient systemBotClient = botManager != null ? botManager.getTelegramClient(BotConstants.SYSTEM_BOT_ID) : null; if (systemBotClient != null) { try { String telegramHtmlMessage = String.format( @@ -402,11 +402,7 @@ public void sendStatsReportNotification(User user, DashboardStatsResponse stats) List keyboard = new ArrayList<>(); InlineKeyboardRow row = new InlineKeyboardRow(); - String telegramUrl = statsUrl; - if (telegramUrl.contains("localhost") || telegramUrl.contains("127.0.0.1")) { - telegramUrl = telegramUrl.replace("localhost", "lvh.me") - .replace("127.0.0.1", "lvh.me"); - } + String telegramUrl = toTelegramCompatibleUrl(statsUrl); row.add(InlineKeyboardButton.builder() .text("🌐 Open Dashboard") @@ -433,4 +429,15 @@ public void sendStatsReportNotification(User user, DashboardStatsResponse stats) } } } + + private String toTelegramCompatibleUrl(String url) { + if (url == null) { + return null; + } + if (url.contains(LOCALHOST) || url.contains(LOOPBACK_IP)) { + return url.replace(LOCALHOST, DEV_TUNNEL_DOMAIN) + .replace(LOOPBACK_IP, DEV_TUNNEL_DOMAIN); + } + return url; + } } diff --git a/backend/src/main/java/com/launchly/support/controller/SupportAppealController.java b/backend/src/main/java/com/launchly/support/controller/SupportAppealController.java index 023a6a4b..4d3bac05 100644 --- a/backend/src/main/java/com/launchly/support/controller/SupportAppealController.java +++ b/backend/src/main/java/com/launchly/support/controller/SupportAppealController.java @@ -1,11 +1,13 @@ package com.launchly.support.controller; +import com.launchly.common.dto.SuccessResponse; +import com.launchly.common.exception.ErrorResponse; import com.launchly.common.idempotency.Idempotent; import com.launchly.common.ratelimit.RateLimit; import com.launchly.common.ratelimit.RateLimitType; -import com.launchly.common.exception.ErrorResponse; import com.launchly.common.utils.MessageUtils; import com.launchly.support.dto.SupportAppealRequest; +import com.launchly.support.service.SupportAppealService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -15,13 +17,13 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; import java.util.concurrent.TimeUnit; @Tag(name = "Support: Public Contact Form", description = "Public support appeal submission for prospective or unauthenticated users") @@ -31,22 +33,21 @@ @RequiredArgsConstructor public class SupportAppealController { + private final SupportAppealService supportAppealService; private final MessageUtils messageUtils; @Operation(summary = "Submit support appeal / contact message", description = "Send a public contact message to the Launchly support staff.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Appeal submitted successfully"), + @ApiResponse(responseCode = "201", description = "Appeal submitted successfully"), @ApiResponse(responseCode = "400", description = "Validation error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @PostMapping("/appeal") @Idempotent @RateLimit(type = RateLimitType.IP, capacity = 3, duration = 1, unit = TimeUnit.HOURS, messageKey = "rate_limit.error.support_appeal") - public ResponseEntity> submitAppeal(@Valid @RequestBody SupportAppealRequest request) { - log.info("Received support appeal from {}: {}", request.getEmail(), request.getMessage()); - return ResponseEntity.ok(Map.of( - "status", "success", - "message", messageUtils.getMessage("support.appeal.success") - )); + public ResponseEntity submitAppeal(@Valid @RequestBody SupportAppealRequest request) { + supportAppealService.submitAppeal(request); + return ResponseEntity.status(HttpStatus.CREATED).body( + SuccessResponse.ok(messageUtils.getMessage("support.appeal.success")) + ); } } - diff --git a/backend/src/main/java/com/launchly/support/controller/UserSupportChatController.java b/backend/src/main/java/com/launchly/support/controller/UserSupportChatController.java index e58ba0af..cc342954 100644 --- a/backend/src/main/java/com/launchly/support/controller/UserSupportChatController.java +++ b/backend/src/main/java/com/launchly/support/controller/UserSupportChatController.java @@ -1,8 +1,8 @@ package com.launchly.support.controller; -import com.launchly.admin.dto.CreateMessageRequest; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; +import com.launchly.support.dto.CreateMessageRequest; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; import com.launchly.common.ratelimit.RateLimit; import com.launchly.common.ratelimit.RateLimitType; import com.launchly.common.exception.ErrorResponse; @@ -18,6 +18,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; @@ -56,7 +57,7 @@ public ResponseEntity> getUserTickets( public ResponseEntity createTicket( @Valid @RequestBody CreateTicketRequest request, @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok(userSupportChatService.createTicket(request, userDetails.getUsername())); + return ResponseEntity.status(HttpStatus.CREATED).body(userSupportChatService.createTicket(request, userDetails.getUsername())); } @Operation(summary = "Get ticket details and chat thread", description = "Retrieve single support ticket including all customer and manager messages.") @@ -82,7 +83,7 @@ public ResponseEntity addMessage( @Parameter(description = "Ticket ID") @PathVariable Long id, @Valid @RequestBody CreateMessageRequest request, @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok(userSupportChatService.addMessage(id, request.getText(), userDetails.getUsername())); + return ResponseEntity.status(HttpStatus.CREATED).body(userSupportChatService.addMessage(id, request.getText(), userDetails.getUsername())); } @Operation(summary = "Update ticket status (e.g. resolve/close)", description = "Mark a support ticket as resolved or closed.") diff --git a/backend/src/main/java/com/launchly/admin/dto/CreateMessageRequest.java b/backend/src/main/java/com/launchly/support/dto/CreateMessageRequest.java similarity index 94% rename from backend/src/main/java/com/launchly/admin/dto/CreateMessageRequest.java rename to backend/src/main/java/com/launchly/support/dto/CreateMessageRequest.java index 2cb0e877..ae507fae 100644 --- a/backend/src/main/java/com/launchly/admin/dto/CreateMessageRequest.java +++ b/backend/src/main/java/com/launchly/support/dto/CreateMessageRequest.java @@ -1,4 +1,4 @@ -package com.launchly.admin.dto; +package com.launchly.support.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -16,4 +16,3 @@ public class CreateMessageRequest { @Schema(description = "Message body text", example = "Ваше звернення розглянуто, доступ відновлено.", requiredMode = Schema.RequiredMode.REQUIRED) private String text; } - diff --git a/backend/src/main/java/com/launchly/support/dto/SupportAppealRequest.java b/backend/src/main/java/com/launchly/support/dto/SupportAppealRequest.java index 50c98b17..d6fe92a4 100644 --- a/backend/src/main/java/com/launchly/support/dto/SupportAppealRequest.java +++ b/backend/src/main/java/com/launchly/support/dto/SupportAppealRequest.java @@ -2,26 +2,31 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; @Schema(description = "Public support appeal / contact form inquiry") -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class SupportAppealRequest { - @Schema(description = "Sender email address", example = "guest@example.com", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank - private String email; +public record SupportAppealRequest( + @Schema(description = "Sender email address", example = "guest@example.com", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String email, - @Schema(description = "Sender name", example = "Guest User") - private String name; + @Schema(description = "Sender name", example = "Guest User") + String name, - @Schema(description = "Inquiry message content", example = "I have a question about the Enterprise pricing plan.", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank - private String message; -} + @Schema(description = "Inquiry message content", example = "I have a question about the Enterprise pricing plan.", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String message +) { + public String getEmail() { + return email; + } + + public String getName() { + return name; + } + public String getMessage() { + return message; + } +} diff --git a/backend/src/main/java/com/launchly/admin/dto/SupportMessageDto.java b/backend/src/main/java/com/launchly/support/dto/SupportMessageDto.java similarity index 96% rename from backend/src/main/java/com/launchly/admin/dto/SupportMessageDto.java rename to backend/src/main/java/com/launchly/support/dto/SupportMessageDto.java index 0309aecd..c22d9856 100644 --- a/backend/src/main/java/com/launchly/admin/dto/SupportMessageDto.java +++ b/backend/src/main/java/com/launchly/support/dto/SupportMessageDto.java @@ -1,4 +1,4 @@ -package com.launchly.admin.dto; +package com.launchly.support.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -31,4 +31,3 @@ public class SupportMessageDto { @Schema(description = "Message sent timestamp") private LocalDateTime timestamp; } - diff --git a/backend/src/main/java/com/launchly/admin/dto/SupportTicketDto.java b/backend/src/main/java/com/launchly/support/dto/SupportTicketDto.java similarity index 98% rename from backend/src/main/java/com/launchly/admin/dto/SupportTicketDto.java rename to backend/src/main/java/com/launchly/support/dto/SupportTicketDto.java index d36764f3..193ed669 100644 --- a/backend/src/main/java/com/launchly/admin/dto/SupportTicketDto.java +++ b/backend/src/main/java/com/launchly/support/dto/SupportTicketDto.java @@ -1,4 +1,4 @@ -package com.launchly.admin.dto; +package com.launchly.support.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -98,8 +98,6 @@ public class SupportTicketDto { @Schema(description = "Time when ticket was claimed by manager") private LocalDateTime claimedAt; - @Schema(description = "Reason provided in block appeal if applicable") private String appealReason; } - diff --git a/backend/src/main/java/com/launchly/admin/entity/SupportMessage.java b/backend/src/main/java/com/launchly/support/entity/SupportMessage.java similarity index 85% rename from backend/src/main/java/com/launchly/admin/entity/SupportMessage.java rename to backend/src/main/java/com/launchly/support/entity/SupportMessage.java index 2dc91a04..3f12eebb 100644 --- a/backend/src/main/java/com/launchly/admin/entity/SupportMessage.java +++ b/backend/src/main/java/com/launchly/support/entity/SupportMessage.java @@ -1,4 +1,4 @@ -package com.launchly.admin.entity; +package com.launchly.support.entity; import com.launchly.auth.entity.User; import com.launchly.common.entity.BaseEntity; @@ -11,6 +11,8 @@ }) @Getter @Setter +@ToString(exclude = {"ticket", "sender"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @NoArgsConstructor @AllArgsConstructor @Builder diff --git a/backend/src/main/java/com/launchly/admin/entity/SupportTicket.java b/backend/src/main/java/com/launchly/support/entity/SupportTicket.java similarity index 90% rename from backend/src/main/java/com/launchly/admin/entity/SupportTicket.java rename to backend/src/main/java/com/launchly/support/entity/SupportTicket.java index dfdfaca5..9cf61610 100644 --- a/backend/src/main/java/com/launchly/admin/entity/SupportTicket.java +++ b/backend/src/main/java/com/launchly/support/entity/SupportTicket.java @@ -1,4 +1,4 @@ -package com.launchly.admin.entity; +package com.launchly.support.entity; import com.launchly.auth.entity.User; import com.launchly.common.entity.BaseEntity; @@ -14,6 +14,8 @@ }) @Getter @Setter +@ToString(exclude = {"user", "assignedManager", "messages"}) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) @NoArgsConstructor @AllArgsConstructor @Builder @@ -54,6 +56,7 @@ public class SupportTicket extends BaseEntity { @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("createdAt ASC") + @org.hibernate.annotations.BatchSize(size = 50) @Builder.Default private List messages = new ArrayList<>(); @@ -79,4 +82,3 @@ public void reopen() { this.status = "ACTIVE"; } } - diff --git a/backend/src/main/java/com/launchly/support/enums/TicketStatus.java b/backend/src/main/java/com/launchly/support/enums/TicketStatus.java new file mode 100644 index 00000000..d7bb34c7 --- /dev/null +++ b/backend/src/main/java/com/launchly/support/enums/TicketStatus.java @@ -0,0 +1,8 @@ +package com.launchly.support.enums; + +public enum TicketStatus { + ACTIVE, + PENDING, + RESOLVED, + CLOSED +} diff --git a/backend/src/main/java/com/launchly/support/mapper/SupportMapper.java b/backend/src/main/java/com/launchly/support/mapper/SupportMapper.java index 4c176fbf..acb499d5 100644 --- a/backend/src/main/java/com/launchly/support/mapper/SupportMapper.java +++ b/backend/src/main/java/com/launchly/support/mapper/SupportMapper.java @@ -1,9 +1,9 @@ package com.launchly.support.mapper; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; -import com.launchly.admin.entity.SupportMessage; -import com.launchly.admin.entity.SupportTicket; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; +import com.launchly.support.entity.SupportMessage; +import com.launchly.support.entity.SupportTicket; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; diff --git a/backend/src/main/java/com/launchly/admin/repository/SupportMessageRepository.java b/backend/src/main/java/com/launchly/support/repository/SupportMessageRepository.java similarity index 85% rename from backend/src/main/java/com/launchly/admin/repository/SupportMessageRepository.java rename to backend/src/main/java/com/launchly/support/repository/SupportMessageRepository.java index 08939b4b..35e4bba0 100644 --- a/backend/src/main/java/com/launchly/admin/repository/SupportMessageRepository.java +++ b/backend/src/main/java/com/launchly/support/repository/SupportMessageRepository.java @@ -1,6 +1,6 @@ -package com.launchly.admin.repository; +package com.launchly.support.repository; -import com.launchly.admin.entity.SupportMessage; +import com.launchly.support.entity.SupportMessage; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java b/backend/src/main/java/com/launchly/support/repository/SupportTicketRepository.java similarity index 86% rename from backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java rename to backend/src/main/java/com/launchly/support/repository/SupportTicketRepository.java index 1351b562..9ee05bb6 100644 --- a/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java +++ b/backend/src/main/java/com/launchly/support/repository/SupportTicketRepository.java @@ -1,6 +1,6 @@ -package com.launchly.admin.repository; +package com.launchly.support.repository; -import com.launchly.admin.entity.SupportTicket; +import com.launchly.support.entity.SupportTicket; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -15,7 +15,7 @@ @Repository public interface SupportTicketRepository extends JpaRepository, JpaSpecificationExecutor { - @EntityGraph(attributePaths = {"user", "assignedManager"}) + @EntityGraph(attributePaths = {"user", "assignedManager", "messages"}) @Query("SELECT t FROM SupportTicket t WHERE t.user.id = :userId ORDER BY t.updatedAt DESC") Optional findFirstByUserId(@Param("userId") Long userId); @@ -32,7 +32,7 @@ public interface SupportTicketRepository extends JpaRepository findByIsFavoriteTrue(Pageable pageable); @Override - @EntityGraph(attributePaths = {"user", "assignedManager"}) + @EntityGraph(attributePaths = {"user", "assignedManager", "messages"}) Optional findById(Long id); @Override diff --git a/backend/src/main/java/com/launchly/support/service/SupportAppealService.java b/backend/src/main/java/com/launchly/support/service/SupportAppealService.java new file mode 100644 index 00000000..fa2cf082 --- /dev/null +++ b/backend/src/main/java/com/launchly/support/service/SupportAppealService.java @@ -0,0 +1,8 @@ +package com.launchly.support.service; + +import com.launchly.support.dto.SupportAppealRequest; + +public interface SupportAppealService { + + void submitAppeal(SupportAppealRequest request); +} diff --git a/backend/src/main/java/com/launchly/support/service/UserSupportChatService.java b/backend/src/main/java/com/launchly/support/service/UserSupportChatService.java index 9283a038..10be2435 100644 --- a/backend/src/main/java/com/launchly/support/service/UserSupportChatService.java +++ b/backend/src/main/java/com/launchly/support/service/UserSupportChatService.java @@ -1,7 +1,7 @@ package com.launchly.support.service; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; import com.launchly.support.dto.CreateTicketRequest; import org.springframework.data.domain.Page; diff --git a/backend/src/main/java/com/launchly/support/service/impl/SupportAppealServiceImpl.java b/backend/src/main/java/com/launchly/support/service/impl/SupportAppealServiceImpl.java new file mode 100644 index 00000000..ea188eb3 --- /dev/null +++ b/backend/src/main/java/com/launchly/support/service/impl/SupportAppealServiceImpl.java @@ -0,0 +1,18 @@ +package com.launchly.support.service.impl; + +import com.launchly.support.dto.SupportAppealRequest; +import com.launchly.support.service.SupportAppealService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SupportAppealServiceImpl implements SupportAppealService { + + @Override + public void submitAppeal(SupportAppealRequest request) { + log.info("Processing public support appeal from email={}, name={}: {}", request.email(), request.name(), request.message()); + } +} diff --git a/backend/src/main/java/com/launchly/support/service/impl/UserSupportChatServiceImpl.java b/backend/src/main/java/com/launchly/support/service/impl/UserSupportChatServiceImpl.java index 74264707..684088de 100644 --- a/backend/src/main/java/com/launchly/support/service/impl/UserSupportChatServiceImpl.java +++ b/backend/src/main/java/com/launchly/support/service/impl/UserSupportChatServiceImpl.java @@ -1,11 +1,11 @@ package com.launchly.support.service.impl; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; -import com.launchly.admin.entity.SupportMessage; -import com.launchly.admin.entity.SupportTicket; -import com.launchly.admin.repository.SupportMessageRepository; -import com.launchly.admin.repository.SupportTicketRepository; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; +import com.launchly.support.entity.SupportMessage; +import com.launchly.support.entity.SupportTicket; +import com.launchly.support.repository.SupportMessageRepository; +import com.launchly.support.repository.SupportTicketRepository; import com.launchly.auth.entity.User; import com.launchly.auth.service.UserQueryService; import com.launchly.common.exception.AppException; diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index dfb025fb..35e92c77 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -10,21 +10,23 @@ server.tomcat.max-connections=10000 spring.datasource.url=jdbc:postgresql://localhost:5432/launchly spring.datasource.driver-class-name=org.postgresql.Driver -spring.datasource.hikari.maximum-pool-size=80 -spring.datasource.hikari.minimum-idle=20 +spring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20} +spring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:10} spring.datasource.hikari.idle-timeout=30000 spring.datasource.hikari.connection-timeout=20000 spring.datasource.hikari.leak-detection-threshold=10000 -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=${SPRING_JPA_HIBERNATE_DDL_AUTO:validate} spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.jdbc.batch_size=50 +spring.jpa.properties.hibernate.default_batch_fetch_size=50 spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true spring.jpa.properties.jakarta.persistence.query.timeout=5000 spring.jpa.open-in-view=false spring.liquibase.change-log=classpath:db/changelog/db-changelog-master.yaml +spring.mvc.async.request-timeout=30000 server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s @@ -34,15 +36,19 @@ app.jwt.refresh-token-expiration=604800000 app.oauth2.redirect-uri=http://localhost:5173/oauth2/callback app.security.super-admin-email=${SUPER_ADMIN_EMAIL:} -management.endpoints.web.exposure.include=health,info,metrics,prometheus,loggers,logfile,httpexchanges +cloudflare.turnstile.enabled=${CLOUDFLARE_TURNSTILE_ENABLED:true} +cloudflare.turnstile.verify-url=${CLOUDFLARE_TURNSTILE_VERIFY_URL:https://challenges.cloudflare.com/turnstile/v0/siteverify} + +management.endpoints.web.exposure.include=health,info,metrics,prometheus management.endpoint.health.show-details=always management.endpoint.health.probes.enabled=true management.endpoint.health.group.readiness.include=db management.endpoint.health.group.liveness.include=ping -management.info.env.enabled=true +management.info.env.enabled=false management.info.java.enabled=true management.info.os.enabled=true management.health.redis.enabled=false +management.health.mail.enabled=false spring.data.redis.host=localhost spring.data.redis.port=6379 diff --git a/backend/src/main/resources/db/changelog/changes/V059__create_ai_chat_sessions_and_messages.sql b/backend/src/main/resources/db/changelog/changes/V059__create_ai_chat_sessions_and_messages.sql new file mode 100644 index 00000000..04224dd6 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/V059__create_ai_chat_sessions_and_messages.sql @@ -0,0 +1,25 @@ +-- V059: Create AI chat sessions and messages tables + +CREATE TABLE IF NOT EXISTS ai_chat_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255), + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ai_chat_sessions_user_updated + ON ai_chat_sessions(user_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS ai_chat_messages ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE, + role VARCHAR(32) NOT NULL, + content TEXT NOT NULL, + tokens_used INT DEFAULT 0, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ai_chat_messages_session_created + ON ai_chat_messages(session_id, created_at ASC); diff --git a/backend/src/main/resources/db/changelog/changes/V060__add_idx_orders_bot_user_bot.sql b/backend/src/main/resources/db/changelog/changes/V060__add_idx_orders_bot_user_bot.sql new file mode 100644 index 00000000..f145e862 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/V060__add_idx_orders_bot_user_bot.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_orders_bot_user_bot ON orders(bot_user_id, bot_id); diff --git a/backend/src/main/resources/db/changelog/changes/V061__add_response_mode_to_bots.sql b/backend/src/main/resources/db/changelog/changes/V061__add_response_mode_to_bots.sql new file mode 100644 index 00000000..37c52f6a --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/V061__add_response_mode_to_bots.sql @@ -0,0 +1 @@ +ALTER TABLE bots ADD COLUMN IF NOT EXISTS response_mode VARCHAR(50) NOT NULL DEFAULT 'ALL'; diff --git a/backend/src/main/resources/db/changelog/changes/V062__add_published_schema_to_flow_schemas.sql b/backend/src/main/resources/db/changelog/changes/V062__add_published_schema_to_flow_schemas.sql new file mode 100644 index 00000000..308dcf5e --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/V062__add_published_schema_to_flow_schemas.sql @@ -0,0 +1,9 @@ +ALTER TABLE flow_schemas +ADD COLUMN IF NOT EXISTS published_nodes jsonb DEFAULT '[]'::jsonb, +ADD COLUMN IF NOT EXISTS published_edges jsonb DEFAULT '[]'::jsonb; + +-- Populate existing schemas so published version matches current nodes/edges +UPDATE flow_schemas +SET published_nodes = nodes, + published_edges = edges +WHERE published_nodes IS NULL OR published_nodes = '[]'::jsonb; diff --git a/backend/src/main/resources/db/changelog/changes/V063__create_bot_user_interactions_table.sql b/backend/src/main/resources/db/changelog/changes/V063__create_bot_user_interactions_table.sql new file mode 100644 index 00000000..949343a3 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/V063__create_bot_user_interactions_table.sql @@ -0,0 +1,15 @@ +-- V063: Create bot user interactions table + +CREATE TABLE IF NOT EXISTS bot_user_interactions ( + id BIGSERIAL PRIMARY KEY, + bot_id BIGINT NOT NULL REFERENCES bots(id) ON DELETE CASCADE, + source_telegram_id BIGINT NOT NULL, + target_telegram_id BIGINT NOT NULL, + interaction_type VARCHAR(64) NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_bui_bot_src_type ON bot_user_interactions(bot_id, source_telegram_id, interaction_type); +CREATE INDEX IF NOT EXISTS idx_bui_bot_tgt_type ON bot_user_interactions(bot_id, target_telegram_id, interaction_type); +CREATE INDEX IF NOT EXISTS idx_bui_bot_src_tgt ON bot_user_interactions(bot_id, source_telegram_id, target_telegram_id); diff --git a/backend/src/main/resources/db/changelog/changes/V064__create_bot_moderation_rules_table.sql b/backend/src/main/resources/db/changelog/changes/V064__create_bot_moderation_rules_table.sql new file mode 100644 index 00000000..688c9b4f --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/V064__create_bot_moderation_rules_table.sql @@ -0,0 +1,23 @@ +-- V064: Create bot moderation rules table + +CREATE TABLE IF NOT EXISTS bot_moderation_rules ( + id BIGSERIAL PRIMARY KEY, + bot_id BIGINT NOT NULL REFERENCES bots(id) ON DELETE CASCADE, + chat_id VARCHAR(128) NOT NULL DEFAULT '*', + thread_id INTEGER, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + anti_forward_enabled BOOLEAN NOT NULL DEFAULT FALSE, + anti_link_enabled BOOLEAN NOT NULL DEFAULT FALSE, + allowed_links TEXT, + stop_words TEXT, + default_profanity_filter BOOLEAN NOT NULL DEFAULT TRUE, + media_mode VARCHAR(32) NOT NULL DEFAULT 'ALL', + action_on_violation VARCHAR(32) NOT NULL DEFAULT 'DELETE_AND_WARN', + warning_template TEXT, + warn_ttl_seconds INTEGER NOT NULL DEFAULT 5, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_bmr_bot_chat ON bot_moderation_rules(bot_id, chat_id); +CREATE INDEX IF NOT EXISTS idx_bmr_bot_enabled ON bot_moderation_rules(bot_id, enabled); diff --git a/backend/src/main/resources/db/changelog/changes/V065__add_captcha_to_bot_moderation_rules.sql b/backend/src/main/resources/db/changelog/changes/V065__add_captcha_to_bot_moderation_rules.sql new file mode 100644 index 00000000..d588cd95 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/V065__add_captcha_to_bot_moderation_rules.sql @@ -0,0 +1,7 @@ +-- V065: Add captcha columns to bot_moderation_rules table + +ALTER TABLE bot_moderation_rules + ADD COLUMN IF NOT EXISTS captcha_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS captcha_mode VARCHAR(32) NOT NULL DEFAULT 'BUTTON', + ADD COLUMN IF NOT EXISTS captcha_timeout_seconds INTEGER NOT NULL DEFAULT 60, + ADD COLUMN IF NOT EXISTS captcha_message_template TEXT; diff --git a/backend/src/main/resources/db/changelog/db-changelog-master.yaml b/backend/src/main/resources/db/changelog/db-changelog-master.yaml index 78ab34dd..4ad733b5 100644 --- a/backend/src/main/resources/db/changelog/db-changelog-master.yaml +++ b/backend/src/main/resources/db/changelog/db-changelog-master.yaml @@ -115,9 +115,17 @@ databaseChangeLog: file: db/changelog/changes/V057__add_version_to_broadcast_support_blog_templates.sql - include: file: db/changelog/changes/V058__create_outbox_events_table.sql - - - - - - + - include: + file: db/changelog/changes/V059__create_ai_chat_sessions_and_messages.sql + - include: + file: db/changelog/changes/V060__add_idx_orders_bot_user_bot.sql + - include: + file: db/changelog/changes/V061__add_response_mode_to_bots.sql + - include: + file: db/changelog/changes/V062__add_published_schema_to_flow_schemas.sql + - include: + file: db/changelog/changes/V063__create_bot_user_interactions_table.sql + - include: + file: db/changelog/changes/V064__create_bot_moderation_rules_table.sql + - include: + file: db/changelog/changes/V065__add_captcha_to_bot_moderation_rules.sql diff --git a/backend/src/main/resources/messages_errors.properties b/backend/src/main/resources/messages_errors.properties index 4f81af5b..bbd9e3ec 100644 --- a/backend/src/main/resources/messages_errors.properties +++ b/backend/src/main/resources/messages_errors.properties @@ -22,6 +22,7 @@ auth.error.current_password_required=Enter current password to change password. auth.error.invalid_current_password=Incorrect current password. auth.error.user_not_found=User not found. auth.error.invalid_credentials=Invalid email or password. +auth.error.captcha_invalid=Captcha verification failed. Please try again. auth.error.invalid_refresh_token=Invalid refresh token. auth.error.refresh_token_expired=Refresh token expired. auth.error.session_not_found=Auth session not found. @@ -155,3 +156,19 @@ rate_limit.error.template_view=Template view recorded recently. idempotency.error.request_in_progress=A request with this Idempotency-Key is currently being processed. Please wait. idempotency.error.invalid_key=Invalid Idempotency-Key format. Must be between 1 and 255 characters. + +billing.error.plan_not_found=Plan not found. +billing.error.subscription_not_found=Subscription not found. +billing.error.default_plan_not_found=Default FREE plan not found. +billing.error.resolve_subscription_failed=Failed to resolve subscription. + +crm.error.conversation_not_found=Conversation not found. +crm.error.pipeline_not_found=Pipeline not found. +crm.error.stage_not_found=Stage not found. +crm.error.order_not_found=Order not found. +crm.error.lead_not_found=Lead not found. +crm.error.label_not_found=Label not found. + +common.error.encryption_failed=Failed to encrypt data. +integration.error.google_sheets_not_connected=Google Sheets integration not connected. +template.error.not_found=Template not found. diff --git a/backend/src/main/resources/messages_errors_uk.properties b/backend/src/main/resources/messages_errors_uk.properties index 8e5bbe8c..5b0fdda2 100644 --- a/backend/src/main/resources/messages_errors_uk.properties +++ b/backend/src/main/resources/messages_errors_uk.properties @@ -22,6 +22,7 @@ auth.error.current_password_required=Введіть поточний парол auth.error.invalid_current_password=Невірний поточний пароль. auth.error.user_not_found=Користувача не знайдено. auth.error.invalid_credentials=Невірний email або пароль. +auth.error.captcha_invalid=Перевірка капчі не вдалася. Будь ласка, спробуйте ще раз. auth.error.invalid_refresh_token=Недійсний токен оновлення. auth.error.refresh_token_expired=Термін дії токена оновлення закінчився. auth.error.session_not_found=Сесію авторизації не знайдено. @@ -155,3 +156,19 @@ rate_limit.error.template_view=Перегляд шаблону вже зафік idempotency.error.request_in_progress=Запит із таким Idempotency-Key зараз обробляється. Будь ласка, зачекайте. idempotency.error.invalid_key=Некоректний формат Idempotency-Key. Довжина має бути від 1 до 255 символів. + +billing.error.plan_not_found=Тарифний план не знайдено. +billing.error.subscription_not_found=Підписку не знайдено. +billing.error.default_plan_not_found=Базовий тариф FREE не знайдено. +billing.error.resolve_subscription_failed=Не вдалося визначити підписку. + +crm.error.conversation_not_found=Діалог не знайдено. +crm.error.pipeline_not_found=Воронку CRM не знайдено. +crm.error.stage_not_found=Етап воронки не знайдено. +crm.error.order_not_found=Замовлення не знайдено. +crm.error.lead_not_found=Лід не знайдено. +crm.error.label_not_found=Мітку не знайдено. + +common.error.encryption_failed=Помилка шифрування даних. +integration.error.google_sheets_not_connected=Інтеграція з Google Таблицями не підключена. +template.error.not_found=Шаблон не знайдено. diff --git a/backend/src/main/resources/messages_system.properties b/backend/src/main/resources/messages_system.properties index c1b82bba..7f1e9692 100644 --- a/backend/src/main/resources/messages_system.properties +++ b/backend/src/main/resources/messages_system.properties @@ -27,6 +27,10 @@ admin.support.system_sender_name=System admin.reason_rules=Violation of platform rules bot.validation.invalid_format=Invalid format. Please enter valid data. +bot.validation.image=Please send a photo. +bot.validation.image_size_limit=Photo size must not exceed 5 MB. Please send a smaller photo. +bot.validation.image_invalid_format=Invalid file format. Please send an image in JPG, PNG, or WEBP format. +bot.validation.text_length_limit=Text is too long (maximum 2000 characters). bot.validation.email=Please enter a valid email address (e.g., name@example.com). bot.validation.phone=Please enter a valid phone number (e.g., +380123456789). bot.validation.number=Please enter a valid number. @@ -35,3 +39,27 @@ bot.system.optin_success=You are successfully opted-in. Now you are able to rece bot.system.auth_success=Hi! You successfully signed up/logged in with Telegram. Thank you! You can now return to the website. bot.system.auth_failed=Failed to authorize: {0} bot.system.use_website=Please use the website to log in or link your account. + +time.less_than_second=less than 1 sec +time.days_hours={0} d. {1} h. +time.days={0} d. +time.hours_minutes={0} h. {1} min. +time.hours={0} h. +time.minutes_seconds={0} min. {1} sec. +time.minutes={0} min. +time.seconds={0} sec. +bot.cooldown.default_message=Please wait {remaining} before trying again! +bot.leaderboard.empty=Leaderboard is empty. +bot.moderation.default_warning_template={first_name}, your message was deleted due to a violation of chat rules! +bot.moderation.default_user=User +bot.moderation.reason.anti_forward=Anti-Forward: Forwarding messages is prohibited +bot.moderation.reason.text_only=Media Mode: Only text messages are allowed in this chat +bot.moderation.reason.media_only=Media Mode: Only media messages are allowed in this chat +bot.moderation.reason.anti_link=Anti-Link: Link ''{0}'' is forbidden +bot.moderation.reason.stop_word=Stop-Words: Forbidden word/phrase found: ''{0}'' +bot.moderation.captcha.default_message={name}, please confirm you are human within {timeout}s to chat! +bot.moderation.captcha.button_human=I am human +bot.moderation.captcha.not_for_you=This verification is not intended for you! +bot.moderation.captcha.success=Verification passed! Welcome to the group! +bot.moderation.captcha.fail=Incorrect answer. Please try again! + diff --git a/backend/src/main/resources/messages_system_uk.properties b/backend/src/main/resources/messages_system_uk.properties index 7ad591fc..fd683c71 100644 --- a/backend/src/main/resources/messages_system_uk.properties +++ b/backend/src/main/resources/messages_system_uk.properties @@ -25,8 +25,12 @@ admin.support.dialog_other_manager_err=Цей діалог веде менедж admin.support.dialog_already_claimed_err=Цей діалог вже закріплено за менеджером {0}. admin.support.system_sender_name=Система admin.reason_rules=Порушення правил платформи -+ + bot.validation.invalid_format=Некоректний формат. Будь ласка, введіть дійсні дані. +bot.validation.image=Будь ласка, надішліть фотографію. +bot.validation.image_size_limit=Розмір фото не повинен перевищувати 5 МБ. Будь ласка, надішліть фото меншого розміру. +bot.validation.image_invalid_format=Некоректний формат файлу. Будь ласка, надішліть зображення у форматі JPG, PNG або WEBP. +bot.validation.text_length_limit=Текст повідомлення занадто довгий (максимум 2000 символів). bot.validation.email=Будь ласка, введіть коректну адресу електронної пошти (наприклад: name@example.com). bot.validation.phone=Будь ласка, введіть коректний номер телефону (наприклад: +380123456789). bot.validation.number=Будь ласка, введіть число. @@ -35,3 +39,26 @@ bot.system.optin_success=Ви успішно підписалися. Тепер bot.system.auth_success=Вітаємо! Ви успішно увійшли через Telegram. Дякуємо! Тепер ви можете повернутися на сайт. bot.system.auth_failed=Не вдалося авторизуватися: {0} bot.system.use_website=Будь ласка, скористайтеся сайтом для входу або прив''язки облікового запису. + +time.less_than_second=менше 1 сек +time.days_hours={0} дн. {1} год. +time.days={0} дн. +time.hours_minutes={0} год. {1} хв. +time.hours={0} год. +time.minutes_seconds={0} хв. {1} сек. +time.minutes={0} хв. +time.seconds={0} сек. +bot.cooldown.default_message=Зачекайте ще {remaining} перед повторною спробою! +bot.leaderboard.empty=Список лідерів порожній. +bot.moderation.default_warning_template={first_name}, ваше повідомлення видалено через порушення правил чату! +bot.moderation.default_user=Користувач +bot.moderation.reason.anti_forward=Anti-Forward: Пересилання повідомлень заборонено +bot.moderation.reason.text_only=Media Mode: У цьому чаті дозволено тільки текстові повідомлення +bot.moderation.reason.media_only=Media Mode: У цьому чаті дозволено тільки медіа-повідомлення +bot.moderation.reason.anti_link=Anti-Link: Посилання ''{0}'' заборонено +bot.moderation.reason.stop_word=Stop-Words: Знайдено заборонене слово / фразу ''{0}'' +bot.moderation.captcha.default_message={name}, будь ласка, підтвердіть, що ви людина протягом {timeout}с, щоб отримати доступ до чату! +bot.moderation.captcha.button_human=Я людина +bot.moderation.captcha.not_for_you=Ця перевірка призначена не для вас! +bot.moderation.captcha.success=Перевірку пройдено! Ласкаво просимо до чату! +bot.moderation.captcha.fail=Неправильна відповідь. Спробуйте ще раз! diff --git a/backend/src/test/java/com/launchly/admin/controller/AdminSupportChatControllerTest.java b/backend/src/test/java/com/launchly/admin/controller/AdminSupportChatControllerTest.java index fb1135b3..ae2062e9 100644 --- a/backend/src/test/java/com/launchly/admin/controller/AdminSupportChatControllerTest.java +++ b/backend/src/test/java/com/launchly/admin/controller/AdminSupportChatControllerTest.java @@ -1,8 +1,8 @@ package com.launchly.admin.controller; -import com.launchly.admin.dto.CreateMessageRequest; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; +import com.launchly.support.dto.CreateMessageRequest; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; import com.launchly.admin.service.AdminSupportChatService; import com.launchly.common.exception.GlobalExceptionHandler; import com.launchly.common.utils.MessageUtils; diff --git a/backend/src/test/java/com/launchly/ai/controller/AiControllerTest.java b/backend/src/test/java/com/launchly/ai/controller/AiControllerTest.java index 50eefe45..1b6a46ae 100644 --- a/backend/src/test/java/com/launchly/ai/controller/AiControllerTest.java +++ b/backend/src/test/java/com/launchly/ai/controller/AiControllerTest.java @@ -3,6 +3,8 @@ import com.launchly.ai.dto.request.AiChatRequest; import com.launchly.ai.dto.request.AiSchemaRequest; import com.launchly.ai.dto.response.AiChatResponse; +import com.launchly.ai.dto.response.AiChatSessionDetailResponse; +import com.launchly.ai.dto.response.AiChatSessionResponse; import com.launchly.ai.dto.response.AiSchemaResponse; import com.launchly.ai.service.AiService; import com.launchly.common.exception.GlobalExceptionHandler; @@ -31,6 +33,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -78,6 +83,61 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m .build(); } + @Test + @DisplayName("GET /api/v1/ai/sessions - Should return user sessions list") + void getSessions_Success() throws Exception { + when(aiService.getSessions(1L)).thenReturn(List.of()); + + mockMvc.perform(get("/api/v1/ai/sessions")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /api/v1/ai/sessions - Should create new session 201 Created") + void createSession_Success() throws Exception { + AiChatSessionResponse res = new AiChatSessionResponse(1L, "New chat", null, null, null); + when(aiService.createSession(any(), eq(1L))).thenReturn(res); + + mockMvc.perform(post("/api/v1/ai/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)); + } + + @Test + @DisplayName("GET /api/v1/ai/sessions/{id} - Should return session details") + void getSessionDetails_Success() throws Exception { + AiChatSessionDetailResponse res = new AiChatSessionDetailResponse(1L, "New chat", null, null, List.of()); + when(aiService.getSessionDetails(eq(1L), eq(1L))).thenReturn(res); + + mockMvc.perform(get("/api/v1/ai/sessions/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)); + } + + @Test + @DisplayName("PATCH /api/v1/ai/sessions/{id} - Should update session title") + void updateSessionTitle_Success() throws Exception { + AiChatSessionResponse res = new AiChatSessionResponse(1L, "Updated title", null, null, null); + when(aiService.updateSessionTitle(eq(1L), any(), eq(1L))).thenReturn(res); + + mockMvc.perform(patch("/api/v1/ai/sessions/1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Updated title\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Updated title")); + } + + @Test + @DisplayName("DELETE /api/v1/ai/sessions/{id} - Should delete session 204 No Content") + void deleteSession_Success() throws Exception { + doNothing().when(aiService).deleteSession(eq(1L), eq(1L)); + + mockMvc.perform(delete("/api/v1/ai/sessions/1")) + .andExpect(status().isNoContent()); + } + @Test @DisplayName("POST /api/v1/ai/chat - Should generate chat reply 200 OK") void chat_Success() throws Exception { @@ -88,7 +148,7 @@ void chat_Success() throws Exception { mockMvc.perform(post("/api/v1/ai/chat") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) + .andExpect(status().isCreated()) .andExpect(jsonPath("$.reply").value("Use @BotFather to get token")); } @@ -113,6 +173,6 @@ void generateSchema_Success() throws Exception { mockMvc.perform(post("/api/v1/ai/generate-schema") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); + .andExpect(status().isCreated()); } } diff --git a/backend/src/test/java/com/launchly/ai/service/impl/AiServiceImplTest.java b/backend/src/test/java/com/launchly/ai/service/impl/AiServiceImplTest.java index 0027a2d3..e7529db7 100644 --- a/backend/src/test/java/com/launchly/ai/service/impl/AiServiceImplTest.java +++ b/backend/src/test/java/com/launchly/ai/service/impl/AiServiceImplTest.java @@ -3,11 +3,21 @@ import tools.jackson.databind.ObjectMapper; import com.launchly.ai.dto.request.AiChatRequest; import com.launchly.ai.dto.request.AiSchemaRequest; +import com.launchly.ai.dto.request.CreateAiSessionRequest; +import com.launchly.ai.dto.request.UpdateAiSessionRequest; import com.launchly.ai.dto.response.AiChatResponse; +import com.launchly.ai.dto.response.AiChatSessionDetailResponse; +import com.launchly.ai.dto.response.AiChatSessionResponse; import com.launchly.ai.dto.response.AiSchemaResponse; import com.launchly.ai.dto.response.AiUsageResponse; +import com.launchly.ai.entity.AiChatMessage; +import com.launchly.ai.entity.AiChatSession; +import com.launchly.ai.repository.AiChatMessageRepository; +import com.launchly.ai.repository.AiChatSessionRepository; import com.launchly.ai.service.AiProviderRouter; import com.launchly.ai.service.AiUsageService; +import com.launchly.auth.entity.User; +import com.launchly.auth.repository.UserRepository; import com.launchly.billing.entity.Plan; import com.launchly.billing.service.PlanLimitService; import com.launchly.common.exception.AppException; @@ -20,7 +30,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -46,16 +59,38 @@ class AiServiceImplTest { @Mock private ObjectMapper objectMapper; + @Mock + private AiChatSessionRepository aiChatSessionRepository; + + @Mock + private AiChatMessageRepository aiChatMessageRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private org.springframework.transaction.support.TransactionTemplate transactionTemplate; + @InjectMocks private AiServiceImpl aiService; private Plan testPlan; + private User testUser; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + org.springframework.transaction.support.TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(null); + }); + testPlan = new Plan(); testPlan.setId(1L); testPlan.setName("PRO"); + + testUser = new User(); + testUser.setId(1L); + testUser.setEmail("test@launchly.app"); } @Test @@ -73,6 +108,71 @@ void chat_Success() { verify(aiUsageService).recordTokenUsage(anyLong(), any(Plan.class), anyInt()); } + @Test + @DisplayName("Should execute chat with session and persist messages") + void chat_WithSession_PersistsMessages() { + AiChatSession session = AiChatSession.builder() + .user(testUser) + .title(null) + .messages(new ArrayList<>()) + .build(); + session.setId(10L); + session.setCreatedAt(LocalDateTime.now()); + session.setUpdatedAt(LocalDateTime.now()); + + when(planLimitService.getActivePlan(1L)).thenReturn(testPlan); + when(aiChatSessionRepository.findByIdAndUserIdWithMessages(10L, 1L)).thenReturn(Optional.of(session)); + when(aiProviderRouter.chat(anyList(), any())).thenReturn("Assistant reply"); + when(aiChatSessionRepository.save(any(AiChatSession.class))).thenAnswer(inv -> inv.getArgument(0)); + when(aiUsageService.getUsage(1L, testPlan)).thenReturn(new AiUsageResponse(100L, 1000L, 900L, 90, "2026-09-01T00:00:00Z")); + + AiChatRequest request = new AiChatRequest(10L, "How to build a bot?", null); + AiChatResponse response = aiService.chat(request, 1L); + + assertThat(response).isNotNull(); + assertThat(response.sessionId()).isEqualTo(10L); + assertThat(response.reply()).isEqualTo("Assistant reply"); + assertThat(response.messages()).hasSize(2); + assertThat(session.getMessages()).hasSize(2); + assertThat(session.getTitle()).isEqualTo("How to build a bot?"); + } + + @Test + @DisplayName("Should create and retrieve AI sessions") + void session_CRUD_Success() { + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + AiChatSession savedSession = AiChatSession.builder() + .user(testUser) + .title("My Test Session") + .messages(new ArrayList<>()) + .build(); + savedSession.setId(5L); + savedSession.setCreatedAt(LocalDateTime.now()); + savedSession.setUpdatedAt(LocalDateTime.now()); + + when(aiChatSessionRepository.save(any(AiChatSession.class))).thenReturn(savedSession); + when(aiChatSessionRepository.findAllByUserIdOrderByUpdatedAtDesc(1L)).thenReturn(List.of(savedSession)); + when(aiChatSessionRepository.findByIdAndUserIdWithMessages(5L, 1L)).thenReturn(Optional.of(savedSession)); + when(aiChatSessionRepository.findByIdAndUserId(5L, 1L)).thenReturn(Optional.of(savedSession)); + + AiChatSessionResponse created = aiService.createSession(new CreateAiSessionRequest("My Test Session"), 1L); + assertThat(created.id()).isEqualTo(5L); + assertThat(created.title()).isEqualTo("My Test Session"); + + List list = aiService.getSessions(1L); + assertThat(list).hasSize(1); + assertThat(list.get(0).id()).isEqualTo(5L); + + AiChatSessionDetailResponse details = aiService.getSessionDetails(5L, 1L); + assertThat(details.id()).isEqualTo(5L); + + AiChatSessionResponse updated = aiService.updateSessionTitle(5L, new UpdateAiSessionRequest("Updated Title"), 1L); + assertThat(updated.title()).isEqualTo("Updated Title"); + + aiService.deleteSession(5L, 1L); + verify(aiChatSessionRepository).delete(savedSession); + } + @Test @DisplayName("Should throw ServiceUnavailable when chat fallback is invoked") void chatFallback_ThrowsServiceUnavailable() { diff --git a/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java index c096a168..d8b0e467 100644 --- a/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java @@ -188,15 +188,11 @@ void updateProfile_ValidRequest_ReturnsOk() throws Exception { @Test @DisplayName("DELETE /api/v1/auth/account - Should delete account and return 204 No Content") void deleteAccount_Success() throws Exception { - UserResponse userResponse = mock(UserResponse.class); - when(userResponse.id()).thenReturn(1L); - when(authService.getCurrentUser("user@launchly.pro")).thenReturn(userResponse); - UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken("user@launchly.pro", null, Collections.emptyList()); mockMvc.perform(delete("/api/v1/auth/account").principal(auth)) .andExpect(status().isNoContent()); - verify(authService, times(1)).deleteUserAccount(1L); + verify(authService, times(1)).deleteAccountByEmail("user@launchly.pro"); } } diff --git a/backend/src/test/java/com/launchly/common/security/OAuth2SuccessHandlerTest.java b/backend/src/test/java/com/launchly/auth/security/OAuth2SuccessHandlerTest.java similarity index 99% rename from backend/src/test/java/com/launchly/common/security/OAuth2SuccessHandlerTest.java rename to backend/src/test/java/com/launchly/auth/security/OAuth2SuccessHandlerTest.java index d3d85df2..7f156e1a 100644 --- a/backend/src/test/java/com/launchly/common/security/OAuth2SuccessHandlerTest.java +++ b/backend/src/test/java/com/launchly/auth/security/OAuth2SuccessHandlerTest.java @@ -1,4 +1,4 @@ -package com.launchly.common.security; +package com.launchly.auth.security; import com.launchly.admin.service.UserAuditService; import com.launchly.auth.entity.Role; diff --git a/backend/src/test/java/com/launchly/auth/service/impl/AuthServiceImplTest.java b/backend/src/test/java/com/launchly/auth/service/impl/AuthServiceImplTest.java index 19cf9902..d678d86e 100644 --- a/backend/src/test/java/com/launchly/auth/service/impl/AuthServiceImplTest.java +++ b/backend/src/test/java/com/launchly/auth/service/impl/AuthServiceImplTest.java @@ -13,11 +13,10 @@ import com.launchly.auth.repository.TelegramAuthSessionRepository; import com.launchly.auth.repository.UserRepository; import com.launchly.auth.service.TokenService; -import com.launchly.billing.repository.SubscriptionRepository; import com.launchly.billing.service.BillingService; -import com.launchly.bot.repository.BotMemberRepository; -import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.service.BotService; import com.launchly.common.exception.AppException; +import com.launchly.common.security.turnstile.TurnstileService; import com.launchly.common.utils.MessageUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -61,13 +60,7 @@ class AuthServiceImplTest { private TelegramAuthSessionRepository telegramAuthSessionRepository; @Mock - private BotRepository botRepository; - - @Mock - private BotMemberRepository botMemberRepository; - - @Mock - private SubscriptionRepository subscriptionRepository; + private BotService botService; @Mock private UserAuditService userAuditService; @@ -75,6 +68,9 @@ class AuthServiceImplTest { @Mock private MessageUtils messageUtils; + @Mock + private TurnstileService turnstileService; + @InjectMocks private AuthServiceImpl authService; @@ -83,6 +79,7 @@ class AuthServiceImplTest { @BeforeEach void setUp() { + lenient().when(turnstileService.verifyToken(any())).thenReturn(true); testUser = User.builder() .email("test@launchly.pro") .password("encoded_pass") @@ -305,12 +302,11 @@ void unlinkTelegram_Success() { @DisplayName("Should cascade delete user and associated entities") void deleteUserAccount_Success() { when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); - when(botRepository.findAllByUserId(1L)).thenReturn(Collections.emptyList()); - when(botMemberRepository.findByUserId(1L)).thenReturn(Collections.emptyList()); - when(subscriptionRepository.findByUserId(1L)).thenReturn(Optional.empty()); authService.deleteUserAccount(1L); + verify(botService, times(1)).deleteAllUserData(1L); + verify(billingService, times(1)).deleteSubscription(1L); verify(userRepository, times(1)).delete(testUser); } @@ -323,4 +319,26 @@ void deleteUserAccount_WhenNotFound_ThrowsNotFound() { .isInstanceOf(AppException.class) .hasFieldOrPropertyWithValue("status", HttpStatus.NOT_FOUND); } + + @Test + @DisplayName("Should throw BadRequest when Turnstile captcha fails on registration") + void register_WhenTurnstileFails_ThrowsBadRequest() { + RegisterRequest request = new RegisterRequest("new@launchly.pro", "secret123", "New User", "invalid_token"); + when(turnstileService.verifyToken("invalid_token")).thenReturn(false); + + assertThatThrownBy(() -> authService.register(request)) + .isInstanceOf(AppException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("Should throw BadRequest when Turnstile captcha fails on login") + void login_WhenTurnstileFails_ThrowsBadRequest() { + LoginRequest request = new LoginRequest("test@launchly.pro", "secret123", "invalid_token"); + when(turnstileService.verifyToken("invalid_token")).thenReturn(false); + + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(AppException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.BAD_REQUEST); + } } diff --git a/backend/src/test/java/com/launchly/auth/service/impl/TokenServiceImplTest.java b/backend/src/test/java/com/launchly/auth/service/impl/TokenServiceImplTest.java index a66a7130..a700e6e9 100644 --- a/backend/src/test/java/com/launchly/auth/service/impl/TokenServiceImplTest.java +++ b/backend/src/test/java/com/launchly/auth/service/impl/TokenServiceImplTest.java @@ -37,8 +37,8 @@ class TokenServiceImplTest { @BeforeEach void setUp() { ReflectionTestUtils.setField(tokenService, "jwtSecret", secretKey); - ReflectionTestUtils.setField(tokenService, "accessTokenExpiration", 900000L); // 15 min - ReflectionTestUtils.setField(tokenService, "refreshTokenExpiration", 604800000L); // 7 days + ReflectionTestUtils.setField(tokenService, "accessTokenExpiration", 900000L); + ReflectionTestUtils.setField(tokenService, "refreshTokenExpiration", 604800000L); testUser = User.builder() .email("test@example.com") diff --git a/backend/src/test/java/com/launchly/billing/service/impl/BillingServiceImplTest.java b/backend/src/test/java/com/launchly/billing/service/impl/BillingServiceImplTest.java index 306c4148..b223ad52 100644 --- a/backend/src/test/java/com/launchly/billing/service/impl/BillingServiceImplTest.java +++ b/backend/src/test/java/com/launchly/billing/service/impl/BillingServiceImplTest.java @@ -23,6 +23,8 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.util.List; @@ -57,6 +59,9 @@ class BillingServiceImplTest { @Mock private StringRedisTemplate stringRedisTemplate; + @Mock + private TransactionTemplate transactionTemplate; + @InjectMocks private BillingServiceImpl billingService; @@ -68,6 +73,15 @@ class BillingServiceImplTest { @BeforeEach void setUp() { + lenient().when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(null); + }); + lenient().doAnswer(invocation -> { + java.util.function.Consumer action = invocation.getArgument(0); + action.accept(null); + return null; + }).when(transactionTemplate).executeWithoutResult(any()); testUser = User.builder().email("billing@launchly.pro").name("Billing User").build(); ReflectionTestUtils.setField(testUser, "id", 1L); diff --git a/backend/src/test/java/com/launchly/billing/service/impl/PlanLimitServiceImplTest.java b/backend/src/test/java/com/launchly/billing/service/impl/PlanLimitServiceImplTest.java index 292aa53d..07a83c05 100644 --- a/backend/src/test/java/com/launchly/billing/service/impl/PlanLimitServiceImplTest.java +++ b/backend/src/test/java/com/launchly/billing/service/impl/PlanLimitServiceImplTest.java @@ -1,5 +1,6 @@ package com.launchly.billing.service.impl; +import com.launchly.auth.repository.UserRepository; import com.launchly.billing.entity.Plan; import com.launchly.billing.entity.Subscription; import com.launchly.billing.entity.SubscriptionStatus; @@ -45,7 +46,7 @@ class PlanLimitServiceImplTest { private EncryptionUtil encryptionUtil; @Mock - private com.launchly.auth.repository.UserRepository userRepository; + private UserRepository userRepository; @InjectMocks private PlanLimitServiceImpl planLimitService; diff --git a/backend/src/test/java/com/launchly/blog/service/impl/AdminBlogServiceImplTest.java b/backend/src/test/java/com/launchly/blog/service/impl/AdminBlogServiceImplTest.java index e1c8bb35..6052bfac 100644 --- a/backend/src/test/java/com/launchly/blog/service/impl/AdminBlogServiceImplTest.java +++ b/backend/src/test/java/com/launchly/blog/service/impl/AdminBlogServiceImplTest.java @@ -1,5 +1,6 @@ package com.launchly.blog.service.impl; +import com.launchly.auth.service.UserQueryService; import com.launchly.blog.dto.BlogArticleDto; import com.launchly.blog.dto.SaveBlogArticleRequest; import com.launchly.blog.entity.BlogArticle; @@ -28,7 +29,7 @@ class AdminBlogServiceImplTest { private BlogArticleRepository blogArticleRepository; @Mock - private com.launchly.auth.service.UserQueryService userQueryService; + private UserQueryService userQueryService; @Mock private BlogMapper blogMapper; diff --git a/backend/src/test/java/com/launchly/bot/controller/BotControllerTest.java b/backend/src/test/java/com/launchly/bot/controller/BotControllerTest.java index 862eb4a0..1d9adac4 100644 --- a/backend/src/test/java/com/launchly/bot/controller/BotControllerTest.java +++ b/backend/src/test/java/com/launchly/bot/controller/BotControllerTest.java @@ -29,9 +29,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; import tools.jackson.databind.ObjectMapper; - import java.util.List; - import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -149,7 +147,7 @@ void getBot_NotFound_ReturnsNotFound() throws Exception { @Test @DisplayName("PUT /api/v1/bots/{id} - Should update bot and return 200 OK") void updateBot_Success_ReturnsOk() throws Exception { - BotUpdateRequest request = new BotUpdateRequest("Updated Name", "New Desc", null, null, null, null); + BotUpdateRequest request = new BotUpdateRequest("Updated Name", "New Desc", null, null, null, null, null); BotResponse mockResponse = mock(BotResponse.class); when(mockResponse.name()).thenReturn("Updated Name"); diff --git a/backend/src/test/java/com/launchly/bot/engine/action/handler/NotifyContactBotActionHandlerTest.java b/backend/src/test/java/com/launchly/bot/engine/action/handler/NotifyContactBotActionHandlerTest.java new file mode 100644 index 00000000..c7f3f34f --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/engine/action/handler/NotifyContactBotActionHandlerTest.java @@ -0,0 +1,85 @@ +package com.launchly.bot.engine.action.handler; + +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.service.TelegramSendService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotifyContactBotActionHandlerTest { + + @Mock + private TelegramSendService telegramSendService; + + @InjectMocks + private NotifyContactBotActionHandler handler; + + @Test + @DisplayName("Should return supported action types") + void getSupportedTypes_Success() { + assertThat(handler.getSupportedTypes()).contains("NOTIFY_CONTACT", "SEND_MESSAGE_TO_CONTACT"); + } + + @Test + @DisplayName("Should resolve target ID and placeholders and send Telegram message") + void execute_SendMessage_Success() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder() + .bot(bot) + .telegramId(100L) + .firstName("Alex") + .username("alex_dev") + .build(); + + Map sessionData = Map.of( + "found_user.telegram_id", "200", + "found_user.first_name", "Oksana" + ); + + Map action = Map.of( + "type", "NOTIFY_CONTACT", + "targetUserId", "{found_user.telegram_id}", + "text", "У вас взаємне співпадіння з {first_name}! Нікнейм: @{username}" + ); + + handler.execute("NOTIFY_CONTACT", action, currentUser, sessionData); + + verify(telegramSendService).sendMessage(1L, 200L, "У вас взаємне співпадіння з Alex! Нікнейм: @alex_dev"); + } + + @Test + @DisplayName("Should automatically resolve target ID from found_user.telegram_id when targetUserId is omitted") + void execute_SendMessage_AutoResolveTarget_Success() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder() + .bot(bot) + .telegramId(100L) + .firstName("Alex") + .build(); + + Map sessionData = Map.of( + "found_user.telegram_id", "300" + ); + + Map action = Map.of( + "type", "NOTIFY_CONTACT", + "text", "Привіт від {first_name}!" + ); + + handler.execute("NOTIFY_CONTACT", action, currentUser, sessionData); + + verify(telegramSendService).sendMessage(1L, 300L, "Привіт від Alex!"); + } +} diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/ActionNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/ActionNodeExecutorTest.java index 0d063a75..319ad73d 100644 --- a/backend/src/test/java/com/launchly/bot/engine/executor/ActionNodeExecutorTest.java +++ b/backend/src/test/java/com/launchly/bot/engine/executor/ActionNodeExecutorTest.java @@ -15,14 +15,19 @@ import com.launchly.integration.service.GoogleSheetsService; import com.launchly.integration.service.MailchimpService; import com.launchly.notification.service.NotificationService; +import com.launchly.bot.engine.action.ActionContactManager; +import com.launchly.bot.engine.action.ActionPlaceholderResolver; +import com.launchly.bot.engine.action.handler.TagBotActionHandler; +import com.launchly.bot.engine.action.handler.UserFieldBotActionHandler; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.generics.TelegramClient; +import tools.jackson.databind.ObjectMapper; import java.util.List; import java.util.Map; @@ -62,11 +67,22 @@ class ActionNodeExecutorTest { @Mock private TelegramClient telegramClient; - @InjectMocks private ActionNodeExecutor executor; private final Position pos = new Position(0.0, 0.0); + @BeforeEach + void setUp() { + ObjectMapper objectMapper = new ObjectMapper(); + ActionPlaceholderResolver placeholderResolver = new ActionPlaceholderResolver(tagRepository, botUserTagRepository, objectMapper); + ActionContactManager contactManager = new ActionContactManager(botUserRepository, stateService, objectMapper); + + TagBotActionHandler tagHandler = new TagBotActionHandler(tagRepository, botUserTagRepository); + UserFieldBotActionHandler userFieldHandler = new UserFieldBotActionHandler(stateService, placeholderResolver, contactManager); + + executor = new ActionNodeExecutor(stateService, List.of(tagHandler, userFieldHandler)); + } + @Test @DisplayName("Should return ACTION type") void getType_Success() { @@ -147,4 +163,32 @@ void execute_SetAndClearUserField_Success() { verify(stateService).setSessionData(1L, 100L, "subscription_status", "active"); verify(stateService).setSessionData(1L, 100L, "temp_code", ""); } + + @Test + @DisplayName("Should clear field from both customFields and chatCustomFields") + void execute_ClearUserField_ClearsBothCustomFieldsAndChatCustomFields() { + Bot bot = Bot.builder().name("TestBot").build(); + bot.setId(1L); + BotUser botUser = BotUser.builder() + .bot(bot) + .telegramId(100L) + .metadata("{\"customFields\":{\"iq\":\"10\",\"other\":\"val\"},\"chatCustomFields\":{\"-5534533581\":{\"iq\":\"25\",\"other\":\"gval\"}}}") + .build(); + + List> actions = List.of( + Map.of("type", "CLEAR_USER_FIELD", "fieldName", "iq") + ); + + FlowNode node = new FlowNode("act-clear", NodeType.ACTION, Map.of("actions", actions), pos); + List edges = List.of(new FlowEdge("e-clear", "act-clear", "next-step", null)); + + String nextNode = executor.execute(node, edges, botUser, null, telegramClient); + + assertThat(nextNode).isEqualTo("next-step"); + verify(stateService).setSessionData(1L, 100L, "iq", ""); + verify(botUserRepository).saveAndFlush(botUser); + assertThat(botUser.getMetadata()).doesNotContain("\"iq\""); + assertThat(botUser.getMetadata()).contains("\"other\":\"val\""); + assertThat(botUser.getMetadata()).contains("\"other\":\"gval\""); + } } diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/ConditionNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/ConditionNodeExecutorTest.java index 9e2c9fc4..fe9602a8 100644 --- a/backend/src/test/java/com/launchly/bot/engine/executor/ConditionNodeExecutorTest.java +++ b/backend/src/test/java/com/launchly/bot/engine/executor/ConditionNodeExecutorTest.java @@ -135,17 +135,14 @@ void execute_VariousOperators_Success() { when(stateService.getSessionData(1L, 111L)).thenReturn(Map.of("email", "alice@example.com", "notes", "")); - // Full name resolution FlowNode node1 = new FlowNode("c1", NodeType.CONDITION, Map.of("variable", "Full Name", "operator", "contains", "value", "Alice"), pos); List edges1 = List.of(new FlowEdge("e1", "c1", "target1", "true")); assertThat(executor.execute(node1, edges1, botUser, new Update(), telegramClient)).isEqualTo("target1"); - // Begins with operator FlowNode node2 = new FlowNode("c2", NodeType.CONDITION, Map.of("variable", "email", "operator", "begins_with", "value", "alice"), pos); List edges2 = List.of(new FlowEdge("e2", "c2", "target2", "true")); assertThat(executor.execute(node2, edges2, botUser, new Update(), telegramClient)).isEqualTo("target2"); - // Empty operator FlowNode node3 = new FlowNode("c3", NodeType.CONDITION, Map.of("variable", "notes", "operator", "empty"), pos); List edges3 = List.of(new FlowEdge("e3", "c3", "target3", "true")); assertThat(executor.execute(node3, edges3, botUser, new Update(), telegramClient)).isEqualTo("target3"); diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/InteractionNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/InteractionNodeExecutorTest.java new file mode 100644 index 00000000..c84359af --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/engine/executor/InteractionNodeExecutorTest.java @@ -0,0 +1,115 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.engine.model.Position; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.repository.BotUserInteractionRepository; +import com.launchly.bot.service.BotDialogStateService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InteractionNodeExecutorTest { + + @Mock + private BotUserInteractionRepository interactionRepository; + + @Mock + private BotDialogStateService stateService; + + @Mock + private TelegramClient telegramClient; + + private InteractionNodeExecutor executor; + private final Position pos = new Position(0.0, 0.0); + + @BeforeEach + void setUp() { + executor = new InteractionNodeExecutor(interactionRepository, stateService); + } + + @Test + @DisplayName("Should return INTERACTION type") + void getType_Success() { + assertThat(executor.getType()).isEqualTo(NodeType.INTERACTION); + } + + @Test + @DisplayName("Should save interaction and route to mutual edge when reverse like exists") + void execute_MutualMatch_Success() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder().bot(bot).telegramId(100L).firstName("Alex").build(); + + when(stateService.getSessionData(1L, 100L)).thenReturn(Map.of("found_user.telegram_id", "200")); + when(interactionRepository.findByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType(1L, 100L, 200L, "like")) + .thenReturn(Optional.empty()); + when(interactionRepository.existsByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType(1L, 200L, 100L, "like")) + .thenReturn(true); + + FlowNode node = new FlowNode("inter-1", NodeType.INTERACTION, Map.of( + "targetUserId", "{found_user.telegram_id}", + "interactionType", "like", + "checkMutual", true, + "mutualType", "like" + ), pos); + + List edges = List.of( + new FlowEdge("e1", "inter-1", "msg-mutual", "mutual"), + new FlowEdge("e2", "inter-1", "query-next", "saved") + ); + + String nextNodeId = executor.execute(node, edges, currentUser, new Update(), telegramClient); + + assertThat(nextNodeId).isEqualTo("msg-mutual"); + verify(interactionRepository).save(any()); + } + + @Test + @DisplayName("Should route to saved edge when not mutual") + void execute_NonMutual_SavedHandle() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder().bot(bot).telegramId(100L).firstName("Alex").build(); + + when(stateService.getSessionData(1L, 100L)).thenReturn(Map.of("found_user.telegram_id", "200")); + when(interactionRepository.findByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType(1L, 100L, 200L, "like")) + .thenReturn(Optional.empty()); + when(interactionRepository.existsByBotIdAndSourceTelegramIdAndTargetTelegramIdAndInteractionType(1L, 200L, 100L, "like")) + .thenReturn(false); + + FlowNode node = new FlowNode("inter-1", NodeType.INTERACTION, Map.of( + "targetUserId", "{found_user.telegram_id}", + "interactionType", "like", + "checkMutual", true, + "mutualType", "like" + ), pos); + + List edges = List.of( + new FlowEdge("e1", "inter-1", "msg-mutual", "mutual"), + new FlowEdge("e2", "inter-1", "query-next", "saved") + ); + + String nextNodeId = executor.execute(node, edges, currentUser, new Update(), telegramClient); + + assertThat(nextNodeId).isEqualTo("query-next"); + verify(interactionRepository).save(any()); + } +} diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/MessageNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/MessageNodeExecutorTest.java index 2a30b21b..f05a2d50 100644 --- a/backend/src/test/java/com/launchly/bot/engine/executor/MessageNodeExecutorTest.java +++ b/backend/src/test/java/com/launchly/bot/engine/executor/MessageNodeExecutorTest.java @@ -103,4 +103,38 @@ void execute_WithCallbackQuery_RoutesToCorrectHandle() { assertThat(nextNode).isEqualTo("checkout-node"); } + + @Test + @DisplayName("Should combine image block and adjacent text block into single SendPhoto with caption") + void execute_ImageAndTextBlock_CombinesIntoSingleSendPhoto() throws Exception { + MessageNodeExecutor executor = new MessageNodeExecutor(stateService, redisTemplate, objectMapper); + + Bot bot = Bot.builder().name("MessageBot").build(); + bot.setId(1L); + BotUser botUser = BotUser.builder().bot(bot).telegramId(123456L).firstName("Elijah").build(); + + List> blocks = List.of( + Map.of("type", "image", "imageUrl", "AgACAgIAAxk..."), + Map.of( + "type", "text", + "text", "{{First Name}}, 18\nAbout text\nCity", + "buttons", List.of(Map.of("label", "Search", "value", "btn_search")) + ) + ); + + FlowNode node = new FlowNode("msg-combined", NodeType.MESSAGE, Map.of("blocks", blocks), pos); + List edges = List.of(new FlowEdge("e1", "msg-combined", "next-step", null)); + + String nextNode = executor.execute(node, edges, botUser, new Update(), telegramClient); + + assertThat(nextNode).isNull(); + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(org.telegram.telegrambots.meta.api.methods.send.SendPhoto.class); + verify(telegramClient).execute(captor.capture()); + verify(telegramClient, never()).execute(any(SendMessage.class)); + + org.telegram.telegrambots.meta.api.methods.send.SendPhoto photo = captor.getValue(); + assertThat(photo.getCaption()).contains("Elijah, 18"); + assertThat(photo.getReplyMarkup()).isNotNull(); + } } diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/ModerationNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/ModerationNodeExecutorTest.java new file mode 100644 index 00000000..1795bcad --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/engine/executor/ModerationNodeExecutorTest.java @@ -0,0 +1,120 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.engine.model.Position; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.service.BotDialogStateService; +import com.launchly.common.utils.MessageUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.message.Message; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ModerationNodeExecutorTest { + + @Mock + private BotDialogStateService stateService; + + @Mock + private TelegramClient telegramClient; + + @Mock + private MessageUtils messageUtils; + + @Mock + private ScheduledExecutorService scheduledExecutor; + + @InjectMocks + private ModerationNodeExecutor executor; + + private Bot testBot; + private BotUser testUser; + private FlowNode node; + private List edges; + + @BeforeEach + void setUp() { + testBot = Bot.builder().name("TestBot").build(); + testBot.setId(1L); + testUser = BotUser.builder().bot(testBot).telegramId(12345L).build(); + testUser.setId(10L); + + node = new FlowNode("node_mod", NodeType.MODERATION, Map.of( + "antiForwardEnabled", true, + "antiLinkEnabled", true, + "stopWords", "forbidden, scam", + "defaultProfanityFilter", true, + "mediaMode", "ALL", + "actionOnViolation", "DELETE_AND_WARN" + ), new Position(0.0, 0.0)); + + edges = List.of( + new FlowEdge("e1", "node_mod", "node_clean", "clean"), + new FlowEdge("e2", "node_mod", "node_violated", "violated") + ); + } + + @Test + @DisplayName("Should return NodeType.MODERATION") + void shouldReturnCorrectType() { + assertThat(executor.getType()).isEqualTo(NodeType.MODERATION); + } + + @Test + @DisplayName("Should pass clean message to clean handle") + void shouldPassCleanMessage() { + Update update = mock(Update.class); + Message message = mock(Message.class); + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.hasText()).thenReturn(true); + when(message.getText()).thenReturn("Hello world, clean message!"); + + String next = executor.execute(node, edges, testUser, update, telegramClient); + + assertThat(next).isEqualTo("node_clean"); + verify(stateService).setSessionData(1L, 12345L, "is_moderation_passed", "true"); + verifyNoInteractions(telegramClient); + } + + @Test + @DisplayName("Should block message with stop-word and route to violated handle") + void shouldBlockStopWord() throws Exception { + Update update = mock(Update.class); + Message message = mock(Message.class); + User user = mock(User.class); + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.hasText()).thenReturn(true); + when(message.getText()).thenReturn("Check out this forbidden deal"); + when(message.getChatId()).thenReturn(999L); + when(message.getMessageId()).thenReturn(101); + when(message.getFrom()).thenReturn(user); + + String next = executor.execute(node, edges, testUser, update, telegramClient); + + assertThat(next).isEqualTo("node_violated"); + verify(stateService).setSessionData(1L, 12345L, "is_moderation_passed", "false"); + verify(telegramClient, atLeastOnce()).execute(any(DeleteMessage.class)); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/QueryNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/QueryNodeExecutorTest.java new file mode 100644 index 00000000..2ae6489c --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/engine/executor/QueryNodeExecutorTest.java @@ -0,0 +1,231 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.engine.model.Position; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.repository.BotUserInteractionRepository; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.service.BotDialogStateService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class QueryNodeExecutorTest { + + @Mock + private BotUserRepository botUserRepository; + + @Mock + private BotUserInteractionRepository interactionRepository; + + @Mock + private BotDialogStateService stateService; + + @Mock + private TelegramClient telegramClient; + + private QueryNodeExecutor executor; + private final Position pos = new Position(0.0, 0.0); + + @BeforeEach + void setUp() { + executor = new QueryNodeExecutor(botUserRepository, interactionRepository, stateService, new ObjectMapper()); + } + + @Test + @DisplayName("Should return QUERY node type") + void getType_Success() { + assertThat(executor.getType()).isEqualTo(NodeType.QUERY); + } + + @Test + @DisplayName("Should query candidate by metadata filter, exclude self and interacted, and route to found edge") + void execute_CandidateFound_Success() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder() + .bot(bot) + .telegramId(100L) + .firstName("Alex") + .metadata("{\"looking_for\":\"female\",\"city\":\"Kyiv\"}") + .build(); + currentUser.setId(1L); + + BotUser candidate1 = BotUser.builder() + .bot(bot) + .telegramId(200L) + .firstName("Oksana") + .username("oksana_kyiv") + .metadata("{\"gender\":\"female\",\"city\":\"Kyiv\",\"age\":\"23\"}") + .build(); + candidate1.setId(2L); + + BotUser candidate2 = BotUser.builder() + .bot(bot) + .telegramId(300L) + .firstName("Ivan") + .username("ivan_lviv") + .metadata("{\"gender\":\"male\",\"city\":\"Lviv\",\"age\":\"25\"}") + .build(); + candidate2.setId(3L); + + when(interactionRepository.findInteractedTargetTelegramIds(eq(1L), eq(100L), any())) + .thenReturn(List.of()); + when(botUserRepository.findAllByBotId(1L)).thenReturn(List.of(currentUser, candidate1, candidate2)); + when(stateService.getSessionData(1L, 100L)).thenReturn(Map.of("city", "Kyiv")); + + FlowNode node = new FlowNode("query-1", NodeType.QUERY, Map.of( + "outputPrefix", "found_user", + "excludeSelf", true, + "excludeInteractions", List.of("like", "dislike"), + "filters", List.of( + Map.of("field", "gender", "operator", "equals", "value", "female"), + Map.of("field", "city", "operator", "equals", "value", "Kyiv") + ) + ), pos); + + List edges = List.of( + new FlowEdge("e1", "query-1", "msg-found", "found"), + new FlowEdge("e2", "query-1", "msg-empty", "not_found") + ); + + String nextNodeId = executor.execute(node, edges, currentUser, new Update(), telegramClient); + + assertThat(nextNodeId).isEqualTo("msg-found"); + verify(stateService).setSessionData(1L, 100L, "found_user.telegram_id", "200"); + verify(stateService).setSessionData(1L, 100L, "found_user.first_name", "Oksana"); + verify(stateService).setSessionData(1L, 100L, "found_user.age", "23"); + } + + @Test + @DisplayName("Should route to not_found handle when no candidates match filters") + void execute_NoCandidates_NotFoundHandle() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder().bot(bot).telegramId(100L).firstName("Alex").build(); + currentUser.setId(1L); + + when(botUserRepository.findAllByBotId(1L)).thenReturn(List.of(currentUser)); + when(stateService.getSessionData(1L, 100L)).thenReturn(Map.of()); + + FlowNode node = new FlowNode("query-1", NodeType.QUERY, Map.of( + "filters", List.of(Map.of("field", "gender", "operator", "equals", "value", "female")) + ), pos); + + List edges = List.of( + new FlowEdge("e1", "query-1", "msg-found", "found"), + new FlowEdge("e2", "query-1", "msg-empty", "not_found") + ); + + String nextNodeId = executor.execute(node, edges, currentUser, new Update(), telegramClient); + assertThat(nextNodeId).isEqualTo("msg-empty"); + } + + @Test + @DisplayName("Should query candidate with nested customFields and resolve placeholder variables from currentUser metadata") + void execute_NestedCustomFieldsAndPlaceholders_Success() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder() + .bot(bot) + .telegramId(100L) + .firstName("Elijah") + .metadata("{\"customFields\":{\"gender\":\"Чоловік\",\"city\":\"Фастів\",\"age\":\"18\"}}") + .build(); + currentUser.setId(1L); + + BotUser candidateFemale = BotUser.builder() + .bot(bot) + .telegramId(200L) + .firstName("Anna") + .metadata("{\"customFields\":{\"gender\":\"Жінка\",\"city\":\"Фастів\",\"age\":\"19\"}}") + .build(); + candidateFemale.setId(2L); + + lenient().when(interactionRepository.findInteractedTargetTelegramIds(eq(1L), eq(100L), any())) + .thenReturn(List.of()); + when(botUserRepository.findAllByBotId(1L)).thenReturn(List.of(currentUser, candidateFemale)); + when(stateService.getSessionData(1L, 100L)).thenReturn(Map.of()); + + FlowNode node = new FlowNode("query-1", NodeType.QUERY, Map.of( + "outputPrefix", "found_user", + "excludeSelf", true, + "filters", List.of( + Map.of("field", "gender", "operator", "not_equals", "value", "gender"), + Map.of("field", "city", "operator", "equals", "value", "{city}") + ) + ), pos); + + List edges = List.of( + new FlowEdge("e1", "query-1", "msg-found", "found"), + new FlowEdge("e2", "query-1", "msg-empty", "not_found") + ); + + String nextNodeId = executor.execute(node, edges, currentUser, new Update(), telegramClient); + + assertThat(nextNodeId).isEqualTo("msg-found"); + verify(stateService).setSessionData(1L, 100L, "found_user.telegram_id", "200"); + verify(stateService).setSessionData(1L, 100L, "found_user.gender", "Жінка"); + verify(stateService).setSessionData(1L, 100L, "found_user.city", "Фастів"); + verify(stateService).setSessionData(1L, 100L, "found_user.age", "19"); + } + + @Test + @DisplayName("Should evaluate greater_than_or_equals and less_than_or_equals operators correctly") + void execute_ComparisonOperators_Success() { + Bot bot = Bot.builder().name("DatingBot").build(); + bot.setId(1L); + BotUser currentUser = BotUser.builder() + .bot(bot) + .telegramId(100L) + .metadata("{\"customFields\":{\"age\":\"20\"}}") + .build(); + currentUser.setId(1L); + + BotUser candidate = BotUser.builder() + .bot(bot) + .telegramId(200L) + .metadata("{\"customFields\":{\"age\":\"20\"}}") + .build(); + candidate.setId(2L); + + when(botUserRepository.findAllByBotId(1L)).thenReturn(List.of(candidate)); + when(stateService.getSessionData(1L, 100L)).thenReturn(Map.of()); + + FlowNode node = new FlowNode("query-gte", NodeType.QUERY, Map.of( + "outputPrefix", "found_user", + "excludeSelf", false, + "filters", List.of( + Map.of("field", "age", "operator", "greater_than_or_equals", "value", "20"), + Map.of("field", "age", "operator", "less_than_or_equals", "value", "20") + ) + ), pos); + + List edges = List.of( + new FlowEdge("e1", "query-gte", "msg-found", "found"), + new FlowEdge("e2", "query-gte", "msg-empty", "not_found") + ); + + String nextNodeId = executor.execute(node, edges, currentUser, new Update(), telegramClient); + + assertThat(nextNodeId).isEqualTo("msg-found"); + } +} diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/RandomizerAndDelayNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/RandomizerAndDelayNodeExecutorTest.java index 0bedf2b1..79a821d8 100644 --- a/backend/src/test/java/com/launchly/bot/engine/executor/RandomizerAndDelayNodeExecutorTest.java +++ b/backend/src/test/java/com/launchly/bot/engine/executor/RandomizerAndDelayNodeExecutorTest.java @@ -79,7 +79,7 @@ void smartDelayNodeExecutor_Success() { String nextNode = executor.execute(node, edges, botUser, new Update(), telegramClient); - assertThat(nextNode).isNull(); // Suspended flow execution + assertThat(nextNode).isNull(); verify(stateService).setSessionData(eq(1L), eq(111L), eq("delay_start_delay-1"), anyString()); } } diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/StartAndEndNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/StartAndEndNodeExecutorTest.java index 6f7ee383..7f5f383c 100644 --- a/backend/src/test/java/com/launchly/bot/engine/executor/StartAndEndNodeExecutorTest.java +++ b/backend/src/test/java/com/launchly/bot/engine/executor/StartAndEndNodeExecutorTest.java @@ -76,6 +76,20 @@ void startAutomationNodeExecutor_Success() { assertThat(nextNodeId).isEqualTo("step-1"); } + @Test + @DisplayName("JoinRequestNodeExecutor should return JOIN_REQUEST type and resolve target node") + void joinRequestNodeExecutor_Success() { + JoinRequestNodeExecutor executor = new JoinRequestNodeExecutor(); + assertThat(executor.getType()).isEqualTo(NodeType.JOIN_REQUEST); + + FlowNode node = new FlowNode("join-1", NodeType.JOIN_REQUEST, Map.of("autoApprove", true), pos); + List edges = List.of(new FlowEdge("e1", "join-1", "msg-gift", "next")); + BotUser botUser = BotUser.builder().telegramId(111L).build(); + + String nextNodeId = executor.execute(node, edges, botUser, new Update(), telegramClient); + assertThat(nextNodeId).isEqualTo("msg-gift"); + } + @Test @DisplayName("CommentNodeExecutor should return COMMENT type and return null") void commentNodeExecutor_Success() { diff --git a/backend/src/test/java/com/launchly/bot/engine/executor/SubscriptionCheckNodeExecutorTest.java b/backend/src/test/java/com/launchly/bot/engine/executor/SubscriptionCheckNodeExecutorTest.java new file mode 100644 index 00000000..b2f242c6 --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/engine/executor/SubscriptionCheckNodeExecutorTest.java @@ -0,0 +1,140 @@ +package com.launchly.bot.engine.executor; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.engine.model.Position; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.service.BotDialogStateService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.methods.groupadministration.GetChatMember; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMember; +import org.telegram.telegrambots.meta.generics.TelegramClient; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SubscriptionCheckNodeExecutorTest { + + @Mock + private BotDialogStateService stateService; + + @Mock + private TelegramClient telegramClient; + + @InjectMocks + private SubscriptionCheckNodeExecutor executor; + + private final Position pos = new Position(0.0, 0.0); + private Bot bot; + private BotUser botUser; + + @BeforeEach + void setUp() { + bot = Bot.builder().name("TestBot").build(); + bot.setId(1L); + botUser = BotUser.builder().bot(bot).telegramId(12345L).firstName("TestUser").build(); + } + + @Test + @DisplayName("Should return SUBSCRIPTION_CHECK type") + void getType_Success() { + assertThat(executor.getType()).isEqualTo(NodeType.SUBSCRIPTION_CHECK); + } + + @Test + @DisplayName("Should route to subscribed handle when user is a member of the required channel") + void execute_SingleChannel_Subscribed() throws Exception { + ChatMember chatMember = mock(ChatMember.class); + when(chatMember.getStatus()).thenReturn("member"); + when(telegramClient.execute(any(GetChatMember.class))).thenReturn(chatMember); + + FlowNode node = new FlowNode("sub-1", NodeType.SUBSCRIPTION_CHECK, Map.of( + "channels", List.of(Map.of("channelId", "@mychannel", "name", "Main Channel", "isRequired", true)), + "mode", "all" + ), pos); + + List edges = List.of( + new FlowEdge("e1", "sub-1", "node-success", "subscribed"), + new FlowEdge("e2", "sub-1", "node-fail", "not_subscribed") + ); + + String result = executor.execute(node, edges, botUser, new Update(), telegramClient); + + assertThat(result).isEqualTo("node-success"); + verify(stateService).setSessionData(1L, 12345L, "is_subscribed", "true"); + verify(stateService).setSessionData(1L, 12345L, "subscribed_channels_count", "1"); + verify(stateService).setSessionData(1L, 12345L, "total_channels_count", "1"); + } + + @Test + @DisplayName("Should route to not_subscribed handle when user is left or kicked") + void execute_SingleChannel_NotSubscribed() throws Exception { + ChatMember chatMember = mock(ChatMember.class); + when(chatMember.getStatus()).thenReturn("left"); + when(telegramClient.execute(any(GetChatMember.class))).thenReturn(chatMember); + + FlowNode node = new FlowNode("sub-1", NodeType.SUBSCRIPTION_CHECK, Map.of( + "channels", List.of(Map.of("channelId", "@mychannel", "name", "Main Channel", "isRequired", true)), + "mode", "all" + ), pos); + + List edges = List.of( + new FlowEdge("e1", "sub-1", "node-success", "subscribed"), + new FlowEdge("e2", "sub-1", "node-fail", "not_subscribed") + ); + + String result = executor.execute(node, edges, botUser, new Update(), telegramClient); + + assertThat(result).isEqualTo("node-fail"); + verify(stateService).setSessionData(1L, 12345L, "is_subscribed", "false"); + verify(stateService).setSessionData(eq(1L), eq(12345L), eq("unsubscribed_channels"), eq("Main Channel")); + } + + @Test + @DisplayName("Should route to subscribed in mode 'any' if user is member in at least one channel") + void execute_MultiChannel_ModeAny_Subscribed() throws Exception { + ChatMember member = mock(ChatMember.class); + when(member.getStatus()).thenReturn("member"); + + when(telegramClient.execute(any(GetChatMember.class))) + .thenThrow(new RuntimeException("Chat not found")) + .thenReturn(member); + + FlowNode node = new FlowNode("sub-2", NodeType.SUBSCRIPTION_CHECK, Map.of( + "channels", List.of( + Map.of("channelId", "@sponsor1", "name", "Sponsor 1", "isRequired", true), + Map.of("channelId", "@sponsor2", "name", "Sponsor 2", "isRequired", true) + ), + "mode", "any" + ), pos); + + List edges = List.of( + new FlowEdge("e1", "sub-2", "node-yes", "subscribed"), + new FlowEdge("e2", "sub-2", "node-no", "not_subscribed") + ); + + String result = executor.execute(node, edges, botUser, new Update(), telegramClient); + + assertThat(result).isEqualTo("node-yes"); + verify(stateService).setSessionData(1L, 12345L, "is_subscribed", "true"); + verify(stateService).setSessionData(1L, 12345L, "subscribed_channels_count", "1"); + verify(stateService).setSessionData(1L, 12345L, "total_channels_count", "2"); + } +} diff --git a/backend/src/test/java/com/launchly/bot/engine/validator/BotInputValidatorTest.java b/backend/src/test/java/com/launchly/bot/engine/validator/BotInputValidatorTest.java index bfe1c26b..d984ad22 100644 --- a/backend/src/test/java/com/launchly/bot/engine/validator/BotInputValidatorTest.java +++ b/backend/src/test/java/com/launchly/bot/engine/validator/BotInputValidatorTest.java @@ -31,9 +31,12 @@ void setUp() { void shouldValidateNumbers() { assertThat(validator.validate("123", "Number")).isTrue(); assertThat(validator.validate("-45.67", "Number")).isTrue(); + assertThat(validator.validate("0", "Number")).isTrue(); assertThat(validator.validate("abc", "Number")).isFalse(); assertThat(validator.validate("", "Number")).isFalse(); assertThat(validator.validate(null, "Number")).isFalse(); + assertThat(validator.validate("123a45", "Number")).isFalse(); + assertThat(validator.validate("1234567890123456789012345", "Number")).isFalse(); } @Test @@ -41,6 +44,9 @@ void shouldValidateNumbers() { void shouldValidateEmail() { assertThat(validator.validate("user@example.com", "Email")).isTrue(); assertThat(validator.validate("invalid-email", "Email")).isFalse(); + assertThat(validator.validate("user@", "Email")).isFalse(); + assertThat(validator.validate("@example.com", "Email")).isFalse(); + assertThat(validator.validate("a".repeat(250) + "@example.com", "Email")).isFalse(); } @Test @@ -49,20 +55,50 @@ void shouldValidatePhone() { assertThat(validator.validate("+380991234567", "Phone")).isTrue(); assertThat(validator.validate("123456", "Phone")).isTrue(); assertThat(validator.validate("not_a_phone", "Phone")).isFalse(); + assertThat(validator.validate("+", "Phone")).isFalse(); + assertThat(validator.validate("+3809912345678901234567890123456", "Phone")).isFalse(); + } + + @Test + @DisplayName("Should validate text length limit") + void shouldValidateTextLength() { + assertThat(validator.validate("Hello world", "Text")).isTrue(); + assertThat(validator.validate("a".repeat(2000), "Text")).isTrue(); + assertThat(validator.validate("a".repeat(2001), "Text")).isFalse(); + } + + @Test + @DisplayName("Should reject text for image validation") + void shouldRejectTextForImage() { + assertThat(validator.validate("some text", "Image")).isFalse(); + assertThat(validator.validate("photo.jpg", "Photo")).isFalse(); } @Test @DisplayName("Should return localized error messages from MessageUtils") void shouldReturnLocalizedErrorMessage() { - when(messageUtils.getMessageWithDefault(eq("bot.validation.email"), anyString())) + when(messageUtils.getMessage(eq("bot.validation.image"))) + .thenReturn("Localized image error"); + when(messageUtils.getMessage(eq("bot.validation.email"))) .thenReturn("Localized email error"); - when(messageUtils.getMessageWithDefault(eq("bot.validation.phone"), anyString())) + when(messageUtils.getMessage(eq("bot.validation.phone"))) .thenReturn("Localized phone error"); - when(messageUtils.getMessageWithDefault(eq("bot.validation.number"), anyString())) + when(messageUtils.getMessage(eq("bot.validation.number"))) .thenReturn("Localized number error"); + when(messageUtils.getMessage(eq("bot.validation.image_size_limit"))) + .thenReturn("Localized image size error"); + when(messageUtils.getMessage(eq("bot.validation.image_invalid_format"))) + .thenReturn("Localized image format error"); + when(messageUtils.getMessage(eq("bot.validation.text_length_limit"))) + .thenReturn("Localized text length error"); + assertThat(validator.getValidationErrorMessage("Image")).isEqualTo("Localized image error"); + assertThat(validator.getValidationErrorMessage("Photo")).isEqualTo("Localized image error"); assertThat(validator.getValidationErrorMessage("Email")).isEqualTo("Localized email error"); assertThat(validator.getValidationErrorMessage("Phone")).isEqualTo("Localized phone error"); assertThat(validator.getValidationErrorMessage("Number")).isEqualTo("Localized number error"); + assertThat(validator.getImageSizeErrorMessage()).isEqualTo("Localized image size error"); + assertThat(validator.getImageFormatErrorMessage()).isEqualTo("Localized image format error"); + assertThat(validator.getTextLengthErrorMessage()).isEqualTo("Localized text length error"); } } diff --git a/backend/src/test/java/com/launchly/bot/entity/BotUserTest.java b/backend/src/test/java/com/launchly/bot/entity/BotUserTest.java new file mode 100644 index 00000000..401c5343 --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/entity/BotUserTest.java @@ -0,0 +1,82 @@ +package com.launchly.bot.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BotUserTest { + + @Test + void getDisplayName_WithFirstAndLastName_ReturnsFullName() { + BotUser user = BotUser.builder() + .firstName("John") + .lastName("Doe") + .username("johndoe") + .telegramId(123456L) + .build(); + + assertEquals("John Doe", user.getDisplayName()); + } + + @Test + void getDisplayName_WithFirstNameOnly_ReturnsFirstName() { + BotUser user = BotUser.builder() + .firstName("Alice") + .lastName(null) + .username("alice_w") + .telegramId(123456L) + .build(); + + assertEquals("Alice", user.getDisplayName()); + } + + @Test + void getDisplayName_WithLastNameOnly_ReturnsLastName() { + BotUser user = BotUser.builder() + .firstName(null) + .lastName("Smith") + .username("smith") + .telegramId(123456L) + .build(); + + assertEquals("Smith", user.getDisplayName()); + } + + @Test + void getDisplayName_WithUsernameOnly_ReturnsUsernameWithAtPrefix() { + BotUser user = BotUser.builder() + .firstName("") + .lastName(" ") + .username("cooluser") + .telegramId(123456L) + .build(); + + assertEquals("@cooluser", user.getDisplayName()); + } + + @Test + void getDisplayName_WithUsernameAlreadyHavingAtPrefix_DoesNotDoubleAt() { + BotUser user = BotUser.builder() + .username("@cooluser") + .telegramId(123456L) + .build(); + + assertEquals("@cooluser", user.getDisplayName()); + } + + @Test + void getDisplayName_WithTelegramIdOnly_ReturnsUserWithTelegramId() { + BotUser user = BotUser.builder() + .telegramId(987654321L) + .build(); + + assertEquals("User 987654321", user.getDisplayName()); + } + + @Test + void getDisplayName_WithAllEmpty_ReturnsUnknown() { + BotUser user = BotUser.builder().build(); + + assertEquals("Unknown", user.getDisplayName()); + } +} diff --git a/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java b/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java index 7b725fdb..c1756910 100644 --- a/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java +++ b/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java @@ -65,7 +65,7 @@ void updateBot_Success() throws Exception { User user = createTestUser("updbot", Role.ROLE_OWNER); Bot bot = createTestBot(user, "Original Bot"); - BotUpdateRequest updateRequest = new BotUpdateRequest("Renamed Bot", "New Description", null, null, null, null); + BotUpdateRequest updateRequest = new BotUpdateRequest("Renamed Bot", "New Description", null, null, null, null, null); mockMvc.perform(put("/api/v1/bots/" + bot.getId()) .header("Authorization", getAuthHeader(user)) diff --git a/backend/src/test/java/com/launchly/bot/scheduler/FlowSchedulerServiceTest.java b/backend/src/test/java/com/launchly/bot/scheduler/FlowSchedulerServiceTest.java new file mode 100644 index 00000000..038388eb --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/scheduler/FlowSchedulerServiceTest.java @@ -0,0 +1,129 @@ +package com.launchly.bot.scheduler; + +import com.launchly.bot.engine.model.FlowEdge; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.engine.model.Position; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.entity.FlowSchema; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.bot.repository.FlowSchemaRepository; +import com.launchly.bot.service.FlowEngineService; +import com.launchly.broadcast.repository.BotUserTagRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import tools.jackson.databind.ObjectMapper; + +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FlowSchedulerServiceTest { + + @Mock + private FlowSchemaRepository flowSchemaRepository; + + @Mock + private BotUserRepository botUserRepository; + + @Mock + private BotUserTagRepository botUserTagRepository; + + @Mock + private FlowEngineService flowEngineService; + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private FlowSchedulerService flowSchedulerService; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + flowSchedulerService = new FlowSchedulerService( + flowSchemaRepository, + botUserRepository, + botUserTagRepository, + flowEngineService, + objectMapper, + redisTemplate + ); + } + + @Test + @DisplayName("Should not process when no active schemas exist") + void shouldSkipWhenNoActiveSchemas() { + when(flowSchemaRepository.findAllByBotActiveTrue()).thenReturn(List.of()); + + flowSchedulerService.processScheduledFlows(); + + verifyNoInteractions(flowEngineService); + } + + @Test + @DisplayName("Should trigger scheduled daily flow when due") + void shouldTriggerDailyFlow() throws Exception { + Bot bot = Bot.builder().active(true).build(); + bot.setId(1L); + BotUser botUser = BotUser.builder().bot(bot).build(); + botUser.setId(10L); + + ZonedDateTime nowKyiv = ZonedDateTime.now(ZoneId.of("Europe/Kyiv")); + String currentTimeStr = nowKyiv.format(DateTimeFormatter.ofPattern("HH:mm")); + + FlowNode schedulerNode = new FlowNode( + "sched-1", + NodeType.SCHEDULER, + Map.of( + "frequency", "daily", + "time", currentTimeStr, + "targetScope", "system", + "timezone", "Europe/Kyiv" + ), + new Position(0, 0) + ); + + FlowNode nextNode = new FlowNode( + "action-1", + NodeType.ACTION, + Map.of(), + new Position(100, 0) + ); + + FlowEdge edge = new FlowEdge("e1", "sched-1", "action-1", "next"); + + FlowSchema schema = FlowSchema.builder() + .bot(bot) + .nodes(objectMapper.writeValueAsString(List.of(schedulerNode, nextNode))) + .edges(objectMapper.writeValueAsString(List.of(edge))) + .build(); + + when(flowSchemaRepository.findAllByBotActiveTrue()).thenReturn(List.of(schema)); + when(valueOperations.get("flow:scheduler:last_run:1:sched-1:daily:" + currentTimeStr)).thenReturn(null); + when(valueOperations.setIfAbsent(eq("lock:flow:scheduler:1:sched-1"), eq("1"), any(Duration.class))).thenReturn(true); + when(botUserRepository.findAllByBotId(1L)).thenReturn(List.of(botUser)); + + flowSchedulerService.processScheduledFlows(); + + verify(flowEngineService).runFlow(eq(1L), eq(botUser), eq("action-1"), isNull()); + } +} diff --git a/backend/src/test/java/com/launchly/bot/service/impl/BotModerationServiceImplTest.java b/backend/src/test/java/com/launchly/bot/service/impl/BotModerationServiceImplTest.java new file mode 100644 index 00000000..5a7fc628 --- /dev/null +++ b/backend/src/test/java/com/launchly/bot/service/impl/BotModerationServiceImplTest.java @@ -0,0 +1,321 @@ +package com.launchly.bot.service.impl; + +import com.launchly.bot.dto.moderation.BotModerationRuleDto; +import com.launchly.bot.dto.moderation.TestModerationRequest; +import com.launchly.bot.dto.moderation.TestModerationResponse; +import com.launchly.bot.dto.moderation.UpdateBotModerationRuleRequest; +import com.launchly.bot.engine.model.FlowNode; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotModerationRule; +import com.launchly.bot.entity.FlowSchema; +import com.launchly.bot.entity.MediaMode; +import com.launchly.bot.entity.NodeType; +import com.launchly.bot.entity.ViolationAction; +import com.launchly.bot.repository.BotModerationRuleRepository; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.repository.FlowSchemaRepository; +import com.launchly.common.utils.MessageUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.message.Message; +import org.telegram.telegrambots.meta.generics.TelegramClient; +import java.util.concurrent.ScheduledExecutorService; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BotModerationServiceImplTest { + + @Mock + private BotModerationRuleRepository ruleRepository; + + @Mock + private BotRepository botRepository; + + @Mock + private FlowSchemaRepository flowSchemaRepository; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private TelegramClient telegramClient; + + @Mock + private MessageUtils messageUtils; + + @Mock + private ScheduledExecutorService scheduledExecutor; + + @InjectMocks + private BotModerationServiceImpl moderationService; + + private Bot testBot; + private BotModerationRule testRule; + + @BeforeEach + void setUp() { + lenient().when(messageUtils.getMessageWithDefault(anyString(), anyString(), any())).thenAnswer(inv -> inv.getArgument(1)); + lenient().when(messageUtils.getMessageWithDefault(anyString(), anyString())).thenAnswer(inv -> inv.getArgument(1)); + lenient().when(messageUtils.getMessage(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + testBot = Bot.builder().name("TestBot").build(); + testBot.setId(1L); + testRule = BotModerationRule.builder() + .bot(testBot) + .chatId("*") + .enabled(true) + .antiForwardEnabled(true) + .antiLinkEnabled(true) + .allowedLinks("launchly.app, github.com") + .stopWords("badword, forbidden") + .defaultProfanityFilter(true) + .mediaMode(MediaMode.ALL) + .actionOnViolation(ViolationAction.DELETE_AND_WARN) + .warnTtlSeconds(5) + .warningTemplate("Warning: {user}!") + .build(); + testRule.setId(100L); + } + + @Test + @DisplayName("Should get moderation settings or return default when none exist") + void shouldGetModerationSettings() { + when(ruleRepository.findByBotIdAndChatId(1L, "*")).thenReturn(Optional.of(testRule)); + + BotModerationRuleDto result = moderationService.getModerationSettings(1L); + + assertThat(result).isNotNull(); + assertThat(result.getBotId()).isEqualTo(1L); + assertThat(result.isEnabled()).isTrue(); + assertThat(result.isAntiForwardEnabled()).isTrue(); + } + + @Test + @DisplayName("Should update moderation settings successfully") + void shouldUpdateModerationSettings() { + when(botRepository.findById(1L)).thenReturn(Optional.of(testBot)); + when(ruleRepository.findByBotIdAndChatId(1L, "*")).thenReturn(Optional.of(testRule)); + when(ruleRepository.save(any(BotModerationRule.class))).thenAnswer(inv -> inv.getArgument(0)); + + UpdateBotModerationRuleRequest request = UpdateBotModerationRuleRequest.builder() + .enabled(true) + .antiForwardEnabled(false) + .antiLinkEnabled(true) + .allowedLinks("example.com") + .stopWords("crypto, scam") + .defaultProfanityFilter(false) + .mediaMode(MediaMode.TEXT_ONLY) + .actionOnViolation(ViolationAction.DELETE_ONLY) + .warnTtlSeconds(10) + .warningTemplate("Alert {user}") + .build(); + + BotModerationRuleDto updated = moderationService.updateModerationSettings(1L, request); + + assertThat(updated).isNotNull(); + assertThat(updated.isAntiForwardEnabled()).isFalse(); + assertThat(updated.getMediaMode()).isEqualTo(MediaMode.TEXT_ONLY); + assertThat(updated.getActionOnViolation()).isEqualTo(ViolationAction.DELETE_ONLY); + } + + @Test + @DisplayName("Should detect stop words in test moderation") + void shouldDetectStopWordsInTest() { + when(ruleRepository.findByBotIdAndChatId(1L, "*")).thenReturn(Optional.of(testRule)); + + TestModerationRequest request = TestModerationRequest.builder() + .text("This contains badword in text") + .forwarded(false) + .hasMedia(false) + .build(); + + TestModerationResponse response = moderationService.testModeration(1L, request); + + assertThat(response.isViolated()).isTrue(); + assertThat(response.getMatchedStopWord()).isEqualTo("badword"); + assertThat(response.getReasons()).anyMatch(r -> r.contains("badword")); + } + + @Test + @DisplayName("Should detect forbidden link in test moderation") + void shouldDetectForbiddenLinkInTest() { + when(ruleRepository.findByBotIdAndChatId(1L, "*")).thenReturn(Optional.of(testRule)); + + TestModerationRequest request = TestModerationRequest.builder() + .text("Visit https://spam-site.com now") + .forwarded(false) + .hasMedia(false) + .build(); + + TestModerationResponse response = moderationService.testModeration(1L, request); + + assertThat(response.isViolated()).isTrue(); + assertThat(response.getReasons()).anyMatch(r -> r.contains("Anti-Link")); + } + + @Test + @DisplayName("Should allow whitelisted link in test moderation") + void shouldAllowWhitelistedLinkInTest() { + when(ruleRepository.findByBotIdAndChatId(1L, "*")).thenReturn(Optional.of(testRule)); + + TestModerationRequest request = TestModerationRequest.builder() + .text("Check out https://github.com/launchly") + .forwarded(false) + .hasMedia(false) + .build(); + + TestModerationResponse response = moderationService.testModeration(1L, request); + + assertThat(response.isViolated()).isFalse(); + } + + @Test + @DisplayName("Should intercept and moderate message containing stop word") + void shouldInterceptAndModerateMessage() throws Exception { + when(ruleRepository.findAllByBotIdAndEnabledTrue(1L)).thenReturn(List.of(testRule)); + + Update update = mock(Update.class); + Message message = mock(Message.class); + User user = mock(User.class); + + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.getChatId()).thenReturn(123456L); + when(message.getMessageId()).thenReturn(999); + when(message.getFrom()).thenReturn(user); + when(message.hasText()).thenReturn(true); + when(message.getText()).thenReturn("Check out this badword here"); + + boolean intercepted = moderationService.processUpdateModeration(1L, update, telegramClient); + + assertThat(intercepted).isTrue(); + verify(telegramClient, atLeastOnce()).execute(any(DeleteMessage.class)); + } + + @Test + @DisplayName("Should return false when FlowSchema MODERATION node is disabled") + void shouldNotModerateWhenFlowSchemaModerationNodeIsDisabled() throws Exception { + FlowSchema schema = new FlowSchema(); + schema.setNodes("[{\"id\":\"mod_1\",\"type\":\"MODERATION\",\"data\":{\"isEnabled\":false,\"stopWords\":[\"badword\"]}}]"); + + when(flowSchemaRepository.findByBotId(1L)).thenReturn(Optional.of(schema)); + FlowNode flowNode = new FlowNode( + "mod_1", + NodeType.MODERATION, + Map.of("isEnabled", false, "stopWords", List.of("badword")), + null + ); + when(objectMapper.readValue(eq(schema.getNodes()), any(TypeReference.class))) + .thenReturn(List.of(flowNode)); + + Update update = mock(Update.class); + Message message = mock(Message.class); + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.getChatId()).thenReturn(123456L); + + boolean intercepted = moderationService.processUpdateModeration(1L, update, telegramClient); + + assertThat(intercepted).isFalse(); + verifyNoInteractions(telegramClient); + } + + @Test + @DisplayName("Should trigger captcha challenge when new member joins and captcha is enabled") + void shouldTriggerCaptchaForNewChatMembers() throws Exception { + testRule.setCaptchaEnabled(true); + testRule.setCaptchaMode(com.launchly.bot.entity.CaptchaMode.BUTTON); + testRule.setCaptchaTimeoutSeconds(60); + when(ruleRepository.findAllByBotIdAndEnabledTrue(1L)).thenReturn(List.of(testRule)); + + Update update = mock(Update.class); + Message message = mock(Message.class); + User newMember = mock(User.class); + when(newMember.getId()).thenReturn(555L); + when(newMember.getFirstName()).thenReturn("NewUser"); + when(newMember.getIsBot()).thenReturn(false); + + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.getChatId()).thenReturn(123456L); + when(message.getNewChatMembers()).thenReturn(List.of(newMember)); + + Message challengeMsg = mock(Message.class); + when(challengeMsg.getMessageId()).thenReturn(777); + doReturn(null).when(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.groupadministration.RestrictChatMember.class)); + doReturn(challengeMsg).when(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.send.SendMessage.class)); + + boolean intercepted = moderationService.processUpdateModeration(1L, update, telegramClient); + + assertThat(intercepted).isTrue(); + verify(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.groupadministration.RestrictChatMember.class)); + verify(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.send.SendMessage.class)); + } + + @Test + @DisplayName("Should approve captcha callback from target user and unmute") + void shouldApproveCaptchaCallback() throws Exception { + Update update = mock(Update.class); + org.telegram.telegrambots.meta.api.objects.CallbackQuery cb = mock(org.telegram.telegrambots.meta.api.objects.CallbackQuery.class); + User user = mock(User.class); + Message origMsg = mock(Message.class); + + when(update.hasCallbackQuery()).thenReturn(true); + when(update.getCallbackQuery()).thenReturn(cb); + when(cb.getData()).thenReturn("mod_captcha:btn:555"); + when(cb.getFrom()).thenReturn(user); + when(cb.getId()).thenReturn("cb_123"); + when(user.getId()).thenReturn(555L); + when(cb.getMessage()).thenReturn(origMsg); + when(origMsg.getChatId()).thenReturn(123456L); + when(origMsg.getMessageId()).thenReturn(777); + + boolean intercepted = moderationService.processUpdateModeration(1L, update, telegramClient); + + assertThat(intercepted).isTrue(); + verify(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.groupadministration.RestrictChatMember.class)); + verify(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage.class)); + verify(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); + } + + @Test + @DisplayName("Should reject captcha callback from different user with alert") + void shouldRejectCaptchaFromNonTargetUser() throws Exception { + Update update = mock(Update.class); + org.telegram.telegrambots.meta.api.objects.CallbackQuery cb = mock(org.telegram.telegrambots.meta.api.objects.CallbackQuery.class); + User otherUser = mock(User.class); + + when(update.hasCallbackQuery()).thenReturn(true); + when(update.getCallbackQuery()).thenReturn(cb); + when(cb.getData()).thenReturn("mod_captcha:btn:555"); + when(cb.getFrom()).thenReturn(otherUser); + when(otherUser.getId()).thenReturn(999L); // Different from 555 + when(cb.getId()).thenReturn("cb_123"); + + boolean intercepted = moderationService.processUpdateModeration(1L, update, telegramClient); + + assertThat(intercepted).isTrue(); + verify(telegramClient).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); + verify(telegramClient, never()).execute(any(org.telegram.telegrambots.meta.api.methods.groupadministration.RestrictChatMember.class)); + } +} diff --git a/backend/src/test/java/com/launchly/bot/service/impl/BotServiceImplTest.java b/backend/src/test/java/com/launchly/bot/service/impl/BotServiceImplTest.java index 7a4905fc..174dd703 100644 --- a/backend/src/test/java/com/launchly/bot/service/impl/BotServiceImplTest.java +++ b/backend/src/test/java/com/launchly/bot/service/impl/BotServiceImplTest.java @@ -1,5 +1,6 @@ package com.launchly.bot.service.impl; +import com.launchly.admin.service.UserAuditService; import com.launchly.auth.entity.Role; import com.launchly.auth.entity.User; import com.launchly.auth.service.UserQueryService; @@ -12,7 +13,10 @@ import com.launchly.bot.entity.Bot; import com.launchly.bot.entity.FlowSchema; import com.launchly.bot.mapper.BotMapper; +import com.launchly.bot.mapper.BotResponseFactory; import com.launchly.bot.repository.*; +import com.launchly.bot.service.BotLifecycleService; +import com.launchly.bot.service.BotSubscriberService; import com.launchly.bot.telegram.TelegramBotManager; import com.launchly.bot.validator.BotAccessValidator; import com.launchly.bot.validator.FlowSchemaValidator; @@ -28,11 +32,9 @@ import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; import tools.jackson.databind.ObjectMapper; - import java.util.Collections; import java.util.List; import java.util.Optional; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; @@ -78,7 +80,7 @@ class BotServiceImplTest { private AccountTemplateRepository accountTemplateRepository; @Mock - private com.launchly.admin.service.UserAuditService userAuditService; + private UserAuditService userAuditService; @Mock private FlowSchemaValidator flowSchemaValidator; @@ -86,6 +88,21 @@ class BotServiceImplTest { @Mock private BotAccessValidator botAccessValidator; + @Mock + private BotLifecycleService botLifecycleService; + + @Mock + private BotSubscriberService botSubscriberService; + + @Mock + private BotResponseFactory botResponseFactory; + + @Mock + private org.springframework.web.client.RestTemplate restTemplate; + + @Mock + private org.springframework.transaction.support.TransactionTemplate transactionTemplate; + @InjectMocks private BotServiceImpl botService; @@ -95,6 +112,11 @@ class BotServiceImplTest { @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + org.springframework.transaction.support.TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(null); + }); + testUser = User.builder() .email("user@launchly.pro") .name("Bot Creator") @@ -122,7 +144,7 @@ void createBot_WithDummyToken_Success() { when(userQueryService.getUserOrThrow(1L)).thenReturn(testUser); when(encryptionUtil.encrypt("0000000000:dummyTokenPlaceholderForNoBotConfig")).thenReturn("encrypted_dummy"); when(botRepository.save(any(Bot.class))).thenReturn(testBot); - when(botMapper.toBotResponse(any(Bot.class))).thenReturn(mockBotResponse); + when(botResponseFactory.toBotResponseWithStats(any(Bot.class))).thenReturn(mockBotResponse); BotResponse response = botService.createBot(request, 1L); @@ -147,9 +169,9 @@ void createBot_WhenLimitReached_ThrowsPaymentRequired() { @Test @DisplayName("Should return all bots owned or shared with the user") void getBotsByUser_Success() { - when(botRepository.findAllByUserId(1L)).thenReturn(List.of(testBot)); + when(botRepository.findAllAccessibleByUserId(1L)).thenReturn(List.of(testBot)); when(botMemberRepository.findByUserId(1L)).thenReturn(Collections.emptyList()); - when(botMapper.toBotResponse(any(Bot.class))).thenReturn(mockBotResponse); + when(botResponseFactory.toBotResponseListWithStats(any(), eq(1L), any())).thenReturn(List.of(mockBotResponse)); List bots = botService.getBotsByUser(1L); @@ -183,10 +205,10 @@ void getBotById_WhenNotFound_ThrowsNotFound() { @Test @DisplayName("Should successfully update bot details") void updateBot_Success() { - BotUpdateRequest request = new BotUpdateRequest("Renamed Bot", "Updated description", null, null, null, null); + BotUpdateRequest request = new BotUpdateRequest("Renamed Bot", "Updated description", null, null, null, null, null); when(botRepository.findByIdAndUserId(10L, 1L)).thenReturn(Optional.of(testBot)); when(botRepository.save(any(Bot.class))).thenReturn(testBot); - when(botMapper.toBotResponse(any(Bot.class))).thenReturn(mockBotResponse); + when(botResponseFactory.toBotResponseWithStats(any(Bot.class))).thenReturn(mockBotResponse); BotResponse response = botService.updateBot(10L, request, 1L); @@ -197,7 +219,7 @@ void updateBot_Success() { @Test @DisplayName("Should throw Forbidden when updating bot without write permissions") void updateBot_WhenAccessDenied_ThrowsForbidden() { - BotUpdateRequest request = new BotUpdateRequest("Renamed Bot", "Updated description", null, null, null, null); + BotUpdateRequest request = new BotUpdateRequest("Renamed Bot", "Updated description", null, null, null, null, null); when(botRepository.findByIdAndUserId(10L, 1L)).thenReturn(Optional.of(testBot)); doThrow(new AppException(HttpStatus.FORBIDDEN, "common.error.access_denied")) .when(botAccessValidator).validateWriteAccess(testBot, 1L); @@ -232,23 +254,19 @@ void deleteBot_WhenUnauthorized_ThrowsForbidden() { @Test @DisplayName("Should start inactive bot with real token") void startBot_Success() { - when(botRepository.findByIdAndUserId(10L, 1L)).thenReturn(Optional.of(testBot)); - when(encryptionUtil.decrypt("encrypted_token_123")).thenReturn("123456:validToken"); - when(botRepository.findAllByActiveTrue()).thenReturn(Collections.emptyList()); - when(botRepository.save(any(Bot.class))).thenReturn(testBot); - when(botMapper.toBotResponse(any(Bot.class))).thenReturn(mockBotResponse); + when(botLifecycleService.startBot(10L, 1L)).thenReturn(mockBotResponse); BotResponse response = botService.startBot(10L, 1L); assertThat(response).isNotNull(); - verify(telegramBotManager, times(1)).registerBot(testBot); + verify(botLifecycleService, times(1)).startBot(10L, 1L); } @Test @DisplayName("Should throw Conflict when starting already running bot") void startBot_WhenAlreadyRunning_ThrowsConflict() { - testBot.setActive(true); - when(botRepository.findByIdAndUserId(10L, 1L)).thenReturn(Optional.of(testBot)); + when(botLifecycleService.startBot(10L, 1L)) + .thenThrow(new AppException(HttpStatus.CONFLICT, "bot.error.already_running")); assertThatThrownBy(() -> botService.startBot(10L, 1L)) .isInstanceOf(AppException.class) @@ -258,9 +276,8 @@ void startBot_WhenAlreadyRunning_ThrowsConflict() { @Test @DisplayName("Should throw BadRequest when starting bot with dummy token") void startBot_WhenDummyToken_ThrowsBadRequest() { - when(botRepository.findByIdAndUserId(10L, 1L)).thenReturn(Optional.of(testBot)); - when(encryptionUtil.decrypt("encrypted_token_123")) - .thenReturn("0000000000:dummyTokenPlaceholderForNoBotConfig"); + when(botLifecycleService.startBot(10L, 1L)) + .thenThrow(new AppException(HttpStatus.BAD_REQUEST, "bot.error.dummy_token")); assertThatThrownBy(() -> botService.startBot(10L, 1L)) .isInstanceOf(AppException.class) @@ -270,22 +287,19 @@ void startBot_WhenDummyToken_ThrowsBadRequest() { @Test @DisplayName("Should stop active bot") void stopBot_Success() { - testBot.setActive(true); - when(botRepository.findByIdAndUserId(10L, 1L)).thenReturn(Optional.of(testBot)); - when(botRepository.save(any(Bot.class))).thenReturn(testBot); - when(botMapper.toBotResponse(any(Bot.class))).thenReturn(mockBotResponse); + when(botLifecycleService.stopBot(10L, 1L)).thenReturn(mockBotResponse); BotResponse response = botService.stopBot(10L, 1L); assertThat(response).isNotNull(); - verify(telegramBotManager, times(1)).unregisterBot(10L); + verify(botLifecycleService, times(1)).stopBot(10L, 1L); } @Test @DisplayName("Should throw Conflict when stopping already inactive bot") void stopBot_WhenAlreadyStopped_ThrowsConflict() { - testBot.setActive(false); - when(botRepository.findByIdAndUserId(10L, 1L)).thenReturn(Optional.of(testBot)); + when(botLifecycleService.stopBot(10L, 1L)) + .thenThrow(new AppException(HttpStatus.CONFLICT, "bot.error.not_running")); assertThatThrownBy(() -> botService.stopBot(10L, 1L)) .isInstanceOf(AppException.class) diff --git a/backend/src/test/java/com/launchly/bot/service/impl/TelegramWebhookServiceImplTest.java b/backend/src/test/java/com/launchly/bot/service/impl/TelegramWebhookServiceImplTest.java index 613b46ce..ed428b09 100644 --- a/backend/src/test/java/com/launchly/bot/service/impl/TelegramWebhookServiceImplTest.java +++ b/backend/src/test/java/com/launchly/bot/service/impl/TelegramWebhookServiceImplTest.java @@ -37,6 +37,9 @@ class TelegramWebhookServiceImplTest { @Mock private StringRedisTemplate stringRedisTemplate; + @Mock + private com.launchly.bot.service.BotModerationService moderationService; + @Mock private ValueOperations valueOperations; @@ -46,7 +49,7 @@ class TelegramWebhookServiceImplTest { void setUp() { lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); lenient().when(valueOperations.setIfAbsent(anyString(), anyString(), any())).thenReturn(true); - webhookService = new TelegramWebhookServiceImpl(flowEngineService, telegramBotManager, rateLimitService, stringRedisTemplate); + webhookService = new TelegramWebhookServiceImpl(flowEngineService, telegramBotManager, rateLimitService, stringRedisTemplate, moderationService); } @Test diff --git a/backend/src/test/java/com/launchly/bot/service/impl/TemplateServiceImplTest.java b/backend/src/test/java/com/launchly/bot/service/impl/TemplateServiceImplTest.java index da1eb083..2ba4afe5 100644 --- a/backend/src/test/java/com/launchly/bot/service/impl/TemplateServiceImplTest.java +++ b/backend/src/test/java/com/launchly/bot/service/impl/TemplateServiceImplTest.java @@ -129,7 +129,7 @@ void createTemplate_WhenUnauthorizedBotAccess_ThrowsForbidden() { when(userQueryService.getUserOrThrow(2L)).thenReturn(otherUser); when(botRepository.findById(10L)).thenReturn(Optional.of(testBot)); - when(botMemberRepository.existsByBotIdAndUserId(10L, 2L)).thenReturn(false); + when(botMemberRepository.existsByBotOwnerIdAndUserId(1L, 2L)).thenReturn(false); assertThatThrownBy(() -> templateService.createTemplate(request, 2L)) .isInstanceOf(AppException.class); diff --git a/backend/src/test/java/com/launchly/broadcast/service/impl/BroadcastServiceImplTest.java b/backend/src/test/java/com/launchly/broadcast/service/impl/BroadcastServiceImplTest.java index 9e352387..69e0a08d 100644 --- a/backend/src/test/java/com/launchly/broadcast/service/impl/BroadcastServiceImplTest.java +++ b/backend/src/test/java/com/launchly/broadcast/service/impl/BroadcastServiceImplTest.java @@ -15,6 +15,7 @@ import com.launchly.broadcast.entity.FilterType; import com.launchly.broadcast.mapper.BroadcastMapper; import com.launchly.broadcast.repository.BroadcastCampaignRepository; +import com.launchly.broadcast.service.BroadcastExecutionService; import com.launchly.broadcast.service.BroadcastFilterService; import com.launchly.broadcast.validator.BroadcastValidator; import com.launchly.common.exception.AppException; @@ -79,6 +80,9 @@ class BroadcastServiceImplTest { @Mock private StringRedisTemplate stringRedisTemplate; + @Mock + private BroadcastExecutionService broadcastExecutionService; + @Mock private ValueOperations valueOperations; diff --git a/backend/src/test/java/com/launchly/chaos/DatabasePoolExhaustionChaosTest.java b/backend/src/test/java/com/launchly/chaos/DatabasePoolExhaustionChaosTest.java index 727db076..cbe525cb 100644 --- a/backend/src/test/java/com/launchly/chaos/DatabasePoolExhaustionChaosTest.java +++ b/backend/src/test/java/com/launchly/chaos/DatabasePoolExhaustionChaosTest.java @@ -13,7 +13,7 @@ class DatabasePoolExhaustionChaosTest { @Test @DisplayName("Chaos: HikariCP connection timeout under pool exhaustion throws SQLTimeoutException cleanly") void hikariConnectionTimeout_UnderPoolExhaustion_ThrowsCleanly() { - AtomicInteger activeConnections = new AtomicInteger(10); // max pool size = 10 + AtomicInteger activeConnections = new AtomicInteger(10); assertThatThrownBy(() -> { int current = activeConnections.get(); @@ -28,7 +28,7 @@ void hikariConnectionTimeout_UnderPoolExhaustion_ThrowsCleanly() { @Test @DisplayName("Chaos: HikariCP pool recovers when busy connections are returned") void hikariPoolRecovery_WhenConnectionsReturned() { - AtomicInteger activeConnections = new AtomicInteger(10); // full pool + AtomicInteger activeConnections = new AtomicInteger(10); activeConnections.addAndGet(-2); assertThat(activeConnections.get()).isEqualTo(8); boolean acquired = activeConnections.incrementAndGet() <= 10; diff --git a/backend/src/test/java/com/launchly/common/idempotency/IdempotencyAspectTest.java b/backend/src/test/java/com/launchly/common/idempotency/IdempotencyAspectTest.java index ef786324..0eb5b4b6 100644 --- a/backend/src/test/java/com/launchly/common/idempotency/IdempotencyAspectTest.java +++ b/backend/src/test/java/com/launchly/common/idempotency/IdempotencyAspectTest.java @@ -1,6 +1,8 @@ package com.launchly.common.idempotency; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.launchly.common.exception.AppException; import com.launchly.common.security.CustomUserDetails; import jakarta.servlet.http.HttpServletRequest; @@ -60,6 +62,8 @@ class IdempotencyAspectTest { @BeforeEach void setUp() { objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); idempotencyAspect = new IdempotencyAspect(stringRedisTemplate, objectMapper); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); lenient().when(idempotent.headerName()).thenReturn("Idempotency-Key"); @@ -167,6 +171,26 @@ void whenExecutionFails_cleansUpProcessingKey() throws Throwable { verify(stringRedisTemplate).delete(contains(":status")); } + @Test + void whenResponseBodyContainsLocalDateTime_serializesWithoutError() throws Throwable { + when(request.getHeader("Idempotency-Key")).thenReturn("test-key-123"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(anyString(), eq("PROCESSING"), any(Duration.class))).thenReturn(true); + + DateTimePayload payload = new DateTimePayload("campaign-1", java.time.LocalDateTime.of(2026, 9, 4, 14, 30)); + ResponseEntity responseEntity = ResponseEntity.ok(payload); + when(joinPoint.proceed()).thenReturn(responseEntity); + + Object result = idempotencyAspect.handleIdempotency(joinPoint, idempotent); + + assertThat(result).isEqualTo(responseEntity); + verify(valueOperations).set(contains(":data"), anyString(), any(Duration.class)); + verify(valueOperations).set(contains(":status"), eq("COMPLETED"), any(Duration.class)); + } + + record DateTimePayload(String name, java.time.LocalDateTime createdAt) {} + static class SampleController { public ResponseEntity> sampleEndpoint() { return ResponseEntity.ok(Map.of("id", 42)); diff --git a/backend/src/test/java/com/launchly/common/ratelimit/TierRateLimitFilterTest.java b/backend/src/test/java/com/launchly/common/ratelimit/TierRateLimitFilterTest.java index 1ad413af..56f14d9f 100644 --- a/backend/src/test/java/com/launchly/common/ratelimit/TierRateLimitFilterTest.java +++ b/backend/src/test/java/com/launchly/common/ratelimit/TierRateLimitFilterTest.java @@ -26,13 +26,15 @@ class TierRateLimitFilterTest { private RateLimitService rateLimitService; private MessageUtils messageUtils; + private tools.jackson.databind.ObjectMapper objectMapper; private TierRateLimitFilter filter; @BeforeEach void setUp() { rateLimitService = mock(RateLimitService.class); messageUtils = mock(MessageUtils.class); - filter = new TierRateLimitFilter(rateLimitService, messageUtils); + objectMapper = new tools.jackson.databind.ObjectMapper(); + filter = new TierRateLimitFilter(rateLimitService, messageUtils, objectMapper); SecurityContextHolder.clearContext(); } diff --git a/backend/src/test/java/com/launchly/common/security/JwtFilterTest.java b/backend/src/test/java/com/launchly/common/security/JwtFilterTest.java index 7ea886bf..f5fba6cc 100644 --- a/backend/src/test/java/com/launchly/common/security/JwtFilterTest.java +++ b/backend/src/test/java/com/launchly/common/security/JwtFilterTest.java @@ -12,12 +12,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import tools.jackson.databind.ObjectMapper; import java.io.PrintWriter; import java.io.StringWriter; @@ -43,6 +45,9 @@ class JwtFilterTest { @Mock private FilterChain filterChain; + @Spy + private ObjectMapper objectMapper = new tools.jackson.databind.ObjectMapper(); + @InjectMocks private JwtFilter jwtFilter; diff --git a/backend/src/test/java/com/launchly/crm/controller/CrmControllerTest.java b/backend/src/test/java/com/launchly/crm/controller/CrmControllerTest.java index c31e0bf9..72973478 100644 --- a/backend/src/test/java/com/launchly/crm/controller/CrmControllerTest.java +++ b/backend/src/test/java/com/launchly/crm/controller/CrmControllerTest.java @@ -104,7 +104,7 @@ void addLabel_Success() throws Exception { mockMvc.perform(post("/api/v1/crm/labels") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("name", "NEW")))) - .andExpect(status().isOk()) + .andExpect(status().isCreated()) .andExpect(jsonPath("$[1]").value("NEW")); } diff --git a/backend/src/test/java/com/launchly/crm/integration/CrmIntegrationTest.java b/backend/src/test/java/com/launchly/crm/integration/CrmIntegrationTest.java index 9e10ac77..49f36315 100644 --- a/backend/src/test/java/com/launchly/crm/integration/CrmIntegrationTest.java +++ b/backend/src/test/java/com/launchly/crm/integration/CrmIntegrationTest.java @@ -52,7 +52,7 @@ void crmLabels_Lifecycle_Success() throws Exception { .header("Authorization", getAuthHeader(user)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("name", "Priority Lead")))) - .andExpect(status().isOk()); + .andExpect(status().isCreated()); mockMvc.perform(get("/api/v1/crm/labels") .header("Authorization", getAuthHeader(user))) @@ -61,7 +61,7 @@ void crmLabels_Lifecycle_Success() throws Exception { mockMvc.perform(delete("/api/v1/crm/labels/Priority Lead") .header("Authorization", getAuthHeader(user))) - .andExpect(status().isOk()); + .andExpect(status().isNoContent()); } @Test diff --git a/backend/src/test/java/com/launchly/crm/service/impl/CrmServiceImplTest.java b/backend/src/test/java/com/launchly/crm/service/impl/CrmServiceImplTest.java index a2ff1bec..f65b5f93 100644 --- a/backend/src/test/java/com/launchly/crm/service/impl/CrmServiceImplTest.java +++ b/backend/src/test/java/com/launchly/crm/service/impl/CrmServiceImplTest.java @@ -17,6 +17,10 @@ import com.launchly.crm.repository.LeadRepository; import com.launchly.crm.repository.MessageRepository; import com.launchly.crm.repository.OrderRepository; +import com.launchly.crm.service.CrmConversationService; +import com.launchly.crm.service.CrmLabelService; +import com.launchly.crm.service.CrmMessageService; +import com.launchly.crm.service.CrmPipelineService; import com.launchly.crm.websocket.CrmWebSocketService; import com.launchly.integration.service.IntegrationEventService; import org.junit.jupiter.api.BeforeEach; @@ -77,6 +81,18 @@ class CrmServiceImplTest { @Mock private ValueOperations valueOperations; + @Mock + private CrmPipelineService crmPipelineService; + + @Mock + private CrmConversationService crmConversationService; + + @Mock + private CrmMessageService crmMessageService; + + @Mock + private CrmLabelService crmLabelService; + @InjectMocks private CrmServiceImpl crmService; @@ -124,37 +140,33 @@ void setUp() { @Test @DisplayName("Should successfully create order, increment sequence and notify WebSocket") void createOrder_Success() { - when(botRepository.findById(1L)).thenReturn(Optional.of(testBot)); - when(botUserRepository.findById(100L)).thenReturn(Optional.of(testBotUser)); - when(orderRepository.save(any(Order.class))).thenReturn(testOrder); - OrderResponse mockOrderResponse = mock(OrderResponse.class); - when(crmMapper.toOrderResponse(any(Order.class))).thenReturn(mockOrderResponse); + when(crmPipelineService.createOrder(1L, 100L, "Coffee Pack", new BigDecimal("250.00"), "UAH")) + .thenReturn(mockOrderResponse); OrderResponse result = crmService.createOrder(1L, 100L, "Coffee Pack", new BigDecimal("250.00"), "UAH"); assertThat(result).isNotNull(); - assertThat(testBot.getOrderSequence()).isEqualTo(11L); - verify(orderRepository, times(1)).save(any(Order.class)); - verify(webSocketService, times(1)).notifyNewOrder(eq(1L), any()); + verify(crmPipelineService, times(1)).createOrder(1L, 100L, "Coffee Pack", new BigDecimal("250.00"), "UAH"); } @Test @DisplayName("Should return orders by bot when user is owner") void getOrdersByBot_Success() { - when(botRepository.findByIdAndUserId(1L, 1L)).thenReturn(Optional.of(testBot)); - when(orderRepository.findByBotIdOrderByCreatedAtDesc(1L)).thenReturn(List.of(testOrder)); - when(crmMapper.toOrderResponseList(List.of(testOrder))).thenReturn(List.of(mock(OrderResponse.class))); + OrderResponse mockOrderResponse = mock(OrderResponse.class); + when(crmPipelineService.getOrdersByBot(1L, 1L)).thenReturn(List.of(mockOrderResponse)); List orders = crmService.getOrdersByBot(1L, 1L); assertThat(orders).hasSize(1); + verify(crmPipelineService, times(1)).getOrdersByBot(1L, 1L); } @Test @DisplayName("Should throw Forbidden when getting orders without bot ownership") void getOrdersByBot_WhenAccessDenied_ThrowsForbidden() { - when(botRepository.findByIdAndUserId(1L, 999L)).thenReturn(Optional.empty()); + when(crmPipelineService.getOrdersByBot(1L, 999L)) + .thenThrow(new AppException(HttpStatus.FORBIDDEN, "crm.error.access_denied")); assertThatThrownBy(() -> crmService.getOrdersByBot(1L, 999L)) .isInstanceOf(AppException.class) @@ -165,23 +177,21 @@ void getOrdersByBot_WhenAccessDenied_ThrowsForbidden() { @DisplayName("Should update order status") void updateOrder_Success() { OrderUpdateRequest request = new OrderUpdateRequest(OrderStatus.COMPLETED, "Delivery to Kyiv"); - when(orderRepository.findById(50L)).thenReturn(Optional.of(testOrder)); - when(botRepository.findByIdAndUserId(1L, 1L)).thenReturn(Optional.of(testBot)); - when(orderRepository.save(testOrder)).thenReturn(testOrder); - when(crmMapper.toOrderResponse(testOrder)).thenReturn(mock(OrderResponse.class)); + OrderResponse mockOrderResponse = mock(OrderResponse.class); + when(crmPipelineService.updateOrder(50L, request, 1L)).thenReturn(mockOrderResponse); OrderResponse response = crmService.updateOrder(50L, request, 1L); assertThat(response).isNotNull(); - assertThat(testOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); - assertThat(testOrder.getNotes()).isEqualTo("Delivery to Kyiv"); + verify(crmPipelineService, times(1)).updateOrder(50L, request, 1L); } @Test @DisplayName("Should throw NotFound when updating non-existent order") void updateOrder_WhenNotFound_ThrowsNotFound() { OrderUpdateRequest request = new OrderUpdateRequest(OrderStatus.COMPLETED, null); - when(orderRepository.findById(999L)).thenReturn(Optional.empty()); + when(crmPipelineService.updateOrder(999L, request, 1L)) + .thenThrow(new AppException(HttpStatus.NOT_FOUND, "crm.error.order_not_found")); assertThatThrownBy(() -> crmService.updateOrder(999L, request, 1L)) .isInstanceOf(AppException.class) @@ -191,51 +201,47 @@ void updateOrder_WhenNotFound_ThrowsNotFound() { @Test @DisplayName("Should successfully create lead") void createLead_Success() { - when(botRepository.findById(1L)).thenReturn(Optional.of(testBot)); - when(botUserRepository.findById(100L)).thenReturn(Optional.of(testBotUser)); - when(leadRepository.save(any(Lead.class))).thenReturn(testLead); - when(crmMapper.toLeadResponse(any(Lead.class))).thenReturn(mock(LeadResponse.class)); + LeadResponse mockLeadResponse = mock(LeadResponse.class); + when(crmPipelineService.createLead(1L, 100L, "Alex Lead", "alex@example.com", "+380991112233", "{}")) + .thenReturn(mockLeadResponse); LeadResponse response = crmService.createLead(1L, 100L, "Alex Lead", "alex@example.com", "+380991112233", "{}"); assertThat(response).isNotNull(); - verify(leadRepository, times(1)).save(any(Lead.class)); - verify(webSocketService, times(1)).notifyNewLead(eq(1L), any()); + verify(crmPipelineService, times(1)).createLead(1L, 100L, "Alex Lead", "alex@example.com", "+380991112233", "{}"); } @Test @DisplayName("Should return leads by bot") void getLeadsByBot_Success() { - when(botRepository.findByIdAndUserId(1L, 1L)).thenReturn(Optional.of(testBot)); - when(leadRepository.findByBotIdOrderByCreatedAtDesc(1L)).thenReturn(List.of(testLead)); - when(crmMapper.toLeadResponseList(List.of(testLead))).thenReturn(List.of(mock(LeadResponse.class))); + LeadResponse mockLeadResponse = mock(LeadResponse.class); + when(crmPipelineService.getLeadsByBot(1L, 1L)).thenReturn(List.of(mockLeadResponse)); List leads = crmService.getLeadsByBot(1L, 1L); assertThat(leads).hasSize(1); + verify(crmPipelineService, times(1)).getLeadsByBot(1L, 1L); } @Test @DisplayName("Should update lead details") void updateLead_Success() { LeadUpdateRequest request = new LeadUpdateRequest(LeadStatus.QUALIFIED, "Interested in Pro plan"); - when(leadRepository.findById(80L)).thenReturn(Optional.of(testLead)); - when(botRepository.findByIdAndUserId(1L, 1L)).thenReturn(Optional.of(testBot)); - when(leadRepository.save(testLead)).thenReturn(testLead); - when(crmMapper.toLeadResponse(testLead)).thenReturn(mock(LeadResponse.class)); + LeadResponse mockLeadResponse = mock(LeadResponse.class); + when(crmPipelineService.updateLead(80L, request, 1L)).thenReturn(mockLeadResponse); LeadResponse response = crmService.updateLead(80L, request, 1L); assertThat(response).isNotNull(); - assertThat(testLead.getStatus()).isEqualTo(LeadStatus.QUALIFIED); - assertThat(testLead.getNotes()).isEqualTo("Interested in Pro plan"); + verify(crmPipelineService, times(1)).updateLead(80L, request, 1L); } @Test @DisplayName("Should throw NotFound when updating non-existent lead") void updateLead_WhenNotFound_ThrowsNotFound() { LeadUpdateRequest request = new LeadUpdateRequest(LeadStatus.QUALIFIED, null); - when(leadRepository.findById(999L)).thenReturn(Optional.empty()); + when(crmPipelineService.updateLead(999L, request, 1L)) + .thenThrow(new AppException(HttpStatus.NOT_FOUND, "crm.error.lead_not_found")); assertThatThrownBy(() -> crmService.updateLead(999L, request, 1L)) .isInstanceOf(AppException.class) @@ -245,7 +251,8 @@ void updateLead_WhenNotFound_ThrowsNotFound() { @Test @DisplayName("Should throw NotFound when getting messages for non-existent conversation") void getMessages_WhenNotFound_ThrowsNotFound() { - when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); + when(crmMessageService.getMessages(999L, 1L)) + .thenThrow(new AppException(HttpStatus.NOT_FOUND, "Conversation not found")); assertThatThrownBy(() -> crmService.getMessages(999L, 1L)) .isInstanceOf(AppException.class) diff --git a/backend/src/test/java/com/launchly/integration/controller/HotmartWebhookControllerTest.java b/backend/src/test/java/com/launchly/integration/controller/HotmartWebhookControllerTest.java index f4d3f257..b2f051e5 100644 --- a/backend/src/test/java/com/launchly/integration/controller/HotmartWebhookControllerTest.java +++ b/backend/src/test/java/com/launchly/integration/controller/HotmartWebhookControllerTest.java @@ -54,7 +54,7 @@ void handleWebhook_Success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(payload)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("ok")); + .andExpect(jsonPath("$.status").value("success")); verify(hotmartService, times(1)).processWebhook(10L, hottok, payload); } diff --git a/backend/src/test/java/com/launchly/integration/service/impl/HotmartServiceImplTest.java b/backend/src/test/java/com/launchly/integration/service/impl/HotmartServiceImplTest.java new file mode 100644 index 00000000..5390614f --- /dev/null +++ b/backend/src/test/java/com/launchly/integration/service/impl/HotmartServiceImplTest.java @@ -0,0 +1,162 @@ +package com.launchly.integration.service.impl; + +import tools.jackson.databind.ObjectMapper; +import com.launchly.bot.entity.Bot; +import com.launchly.bot.entity.BotUser; +import com.launchly.bot.repository.BotRepository; +import com.launchly.bot.repository.BotUserRepository; +import com.launchly.common.exception.AppException; +import com.launchly.crm.entity.Lead; +import com.launchly.crm.entity.Order; +import com.launchly.crm.repository.LeadRepository; +import com.launchly.crm.repository.OrderRepository; +import com.launchly.integration.entity.Integration; +import com.launchly.integration.entity.IntegrationType; +import com.launchly.integration.repository.IntegrationRepository; +import com.launchly.integration.service.IntegrationEventService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class HotmartServiceImplTest { + + @Mock + private IntegrationRepository integrationRepository; + + @Mock + private BotRepository botRepository; + + @Mock + private BotUserRepository botUserRepository; + + @Mock + private LeadRepository leadRepository; + + @Mock + private OrderRepository orderRepository; + + @Mock + private IntegrationEventService integrationEventService; + + private ObjectMapper objectMapper; + + private HotmartServiceImpl hotmartService; + + private Bot testBot; + private Integration testIntegration; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + hotmartService = new HotmartServiceImpl( + integrationRepository, + botRepository, + botUserRepository, + leadRepository, + orderRepository, + integrationEventService, + objectMapper + ); + + testBot = Bot.builder().name("TestBot").orderSequence(10L).build(); + testBot.setId(1L); + + testIntegration = Integration.builder() + .bot(testBot) + .type(IntegrationType.HOTMART) + .active(true) + .config("{\"hottok\":\"secret_hotmart_token\"}") + .build(); + testIntegration.setId(100L); + } + + @Test + @DisplayName("Should throw BAD_REQUEST when rawPayload is missing or empty") + void processWebhook_MissingPayload_ThrowsBadRequest() { + assertThatThrownBy(() -> hotmartService.processWebhook(1L, "token", "")) + .isInstanceOf(AppException.class) + .satisfies(ex -> assertThat(((AppException) ex).getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + @DisplayName("Should throw NOT_FOUND when bot does not exist") + void processWebhook_BotNotFound_ThrowsNotFound() { + when(botRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> hotmartService.processWebhook(99L, "token", "{}")) + .isInstanceOf(AppException.class) + .satisfies(ex -> assertThat(((AppException) ex).getStatus()).isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + @DisplayName("Should throw UNAUTHORIZED when token mismatch occurs") + void processWebhook_InvalidToken_ThrowsUnauthorized() { + when(botRepository.findById(1L)).thenReturn(Optional.of(testBot)); + when(integrationRepository.findByBotIdAndType(1L, IntegrationType.HOTMART)).thenReturn(Optional.of(testIntegration)); + + String payload = "{\"hottok\":\"wrong_token\",\"event\":\"PURCHASE_COMPLETE\"}"; + + assertThatThrownBy(() -> hotmartService.processWebhook(1L, "wrong_token", payload)) + .isInstanceOf(AppException.class) + .satisfies(ex -> assertThat(((AppException) ex).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + @DisplayName("Should successfully process webhook with valid token and constant-time match") + void processWebhook_ValidToken_Success() { + when(botRepository.findById(1L)).thenReturn(Optional.of(testBot)); + when(integrationRepository.findByBotIdAndType(1L, IntegrationType.HOTMART)).thenReturn(Optional.of(testIntegration)); + + BotUser botUser = BotUser.builder().bot(testBot).telegramId(12345L).build(); + botUser.setId(50L); + when(botUserRepository.findByTelegramIdAndBotId(any(), eq(1L))).thenReturn(Optional.of(botUser)); + + Lead lead = Lead.builder().bot(testBot).botUser(botUser).build(); + lead.setId(200L); + when(leadRepository.save(any(Lead.class))).thenReturn(lead); + + Order order = Order.builder().bot(testBot).botUser(botUser).orderNumber("#11").build(); + order.setId(300L); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + String payload = """ + { + "event": "PURCHASE_COMPLETE", + "hottok": "secret_hotmart_token", + "data": { + "buyer": { + "name": "Jane Doe", + "email": "jane@example.com" + }, + "product": { + "name": "Course Pro" + }, + "purchase": { + "status": "APPROVED", + "price": { "value": 99.0 }, + "transaction": "TX-1001" + } + } + } + """; + + hotmartService.processWebhook(1L, "secret_hotmart_token", payload); + + verify(leadRepository).save(any(Lead.class)); + verify(orderRepository).save(any(Order.class)); + verify(integrationEventService).onLeadCreated(lead); + verify(integrationEventService).onOrderCreated(order); + } +} diff --git a/backend/src/test/java/com/launchly/support/controller/SupportAppealControllerTest.java b/backend/src/test/java/com/launchly/support/controller/SupportAppealControllerTest.java index e2f3459a..82b8a830 100644 --- a/backend/src/test/java/com/launchly/support/controller/SupportAppealControllerTest.java +++ b/backend/src/test/java/com/launchly/support/controller/SupportAppealControllerTest.java @@ -3,6 +3,7 @@ import com.launchly.common.exception.GlobalExceptionHandler; import com.launchly.common.utils.MessageUtils; import com.launchly.support.dto.SupportAppealRequest; +import com.launchly.support.service.SupportAppealService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,6 +27,9 @@ class SupportAppealControllerTest { private MockMvc mockMvc; private ObjectMapper objectMapper; + @Mock + private SupportAppealService supportAppealService; + @Mock private MessageUtils messageUtils; @@ -50,7 +54,7 @@ void submitAppeal_Success() throws Exception { mockMvc.perform(post("/api/v1/support/appeal") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) + .andExpect(status().isCreated()) .andExpect(jsonPath("$.status").value("success")); } diff --git a/backend/src/test/java/com/launchly/support/controller/UserSupportChatControllerTest.java b/backend/src/test/java/com/launchly/support/controller/UserSupportChatControllerTest.java index a594d07d..b87eb583 100644 --- a/backend/src/test/java/com/launchly/support/controller/UserSupportChatControllerTest.java +++ b/backend/src/test/java/com/launchly/support/controller/UserSupportChatControllerTest.java @@ -1,6 +1,6 @@ package com.launchly.support.controller; -import com.launchly.admin.dto.SupportTicketDto; +import com.launchly.support.dto.SupportTicketDto; import com.launchly.common.exception.GlobalExceptionHandler; import com.launchly.common.utils.MessageUtils; import com.launchly.support.dto.CreateTicketRequest; @@ -97,6 +97,6 @@ void createTicket_Success() throws Exception { mockMvc.perform(post("/api/v1/support/tickets") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); + .andExpect(status().isCreated()); } } diff --git a/backend/src/test/java/com/launchly/support/mapper/SupportMapperTest.java b/backend/src/test/java/com/launchly/support/mapper/SupportMapperTest.java index 93cde40a..16874f4b 100644 --- a/backend/src/test/java/com/launchly/support/mapper/SupportMapperTest.java +++ b/backend/src/test/java/com/launchly/support/mapper/SupportMapperTest.java @@ -1,9 +1,9 @@ package com.launchly.support.mapper; -import com.launchly.admin.dto.SupportMessageDto; -import com.launchly.admin.dto.SupportTicketDto; -import com.launchly.admin.entity.SupportMessage; -import com.launchly.admin.entity.SupportTicket; +import com.launchly.support.dto.SupportMessageDto; +import com.launchly.support.dto.SupportTicketDto; +import com.launchly.support.entity.SupportMessage; +import com.launchly.support.entity.SupportTicket; import com.launchly.auth.entity.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/backend/src/test/java/com/launchly/support/service/impl/UserSupportChatServiceImplTest.java b/backend/src/test/java/com/launchly/support/service/impl/UserSupportChatServiceImplTest.java index 6337ae22..b9a3f0be 100644 --- a/backend/src/test/java/com/launchly/support/service/impl/UserSupportChatServiceImplTest.java +++ b/backend/src/test/java/com/launchly/support/service/impl/UserSupportChatServiceImplTest.java @@ -1,9 +1,9 @@ package com.launchly.support.service.impl; -import com.launchly.admin.dto.SupportTicketDto; -import com.launchly.admin.entity.SupportTicket; -import com.launchly.admin.repository.SupportMessageRepository; -import com.launchly.admin.repository.SupportTicketRepository; +import com.launchly.support.dto.SupportTicketDto; +import com.launchly.support.entity.SupportTicket; +import com.launchly.support.repository.SupportMessageRepository; +import com.launchly.support.repository.SupportTicketRepository; import com.launchly.auth.entity.User; import com.launchly.auth.service.UserQueryService; import com.launchly.common.utils.MessageUtils; diff --git a/backend/src/test/resources/docker-java.properties b/backend/src/test/resources/docker-java.properties new file mode 100644 index 00000000..d06ebb92 --- /dev/null +++ b/backend/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/docs/API_KEYS_GUIDE.md b/docs/API_KEYS_GUIDE.md index 4850b9e6..8f93d5ae 100644 --- a/docs/API_KEYS_GUIDE.md +++ b/docs/API_KEYS_GUIDE.md @@ -86,7 +86,22 @@ Enables one-click Google login on frontend and backend integration with Google S --- -## 7. Security & Encryption Keys +## 7. Cloudflare Turnstile (Bot & Anti-Abuse Protection) + +Cloudflare Turnstile provides smart, privacy-friendly bot verification on authentication forms (Login and Register) to prevent automated credential stuffing and spam registrations. + +| Variable | Scope | Description | Portal / Signup | +| :--- | :--- | :--- | :--- | +| `CLOUDFLARE_TURNSTILE_SECRET_KEY` | Backend | Turnstile Secret Key for `/siteverify` validation | [dash.cloudflare.com/?to=/:account/turnstile](https://dash.cloudflare.com/?to=/:account/turnstile) | +| `VITE_CLOUDFLARE_TURNSTILE_SITE_KEY` | Frontend | Turnstile Public Site Key for widget rendering | [dash.cloudflare.com/?to=/:account/turnstile](https://dash.cloudflare.com/?to=/:account/turnstile) | + +> **Development Note**: For local development and testing, Cloudflare provides dummy test keys: +> - Site Key (Always Passes): `1x00000000000000000000AA` +> - Secret Key (Always Passes): `1x0000000000000000000000000000000AA` + +--- + +## 8. Security & Encryption Keys | Variable | Description | Requirements | | :--- | :--- | :--- | diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 9d17a34c..216c75df 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -46,6 +46,9 @@ CLOUDINARY_API_SECRET=your_api_secret # Stripe STRIPE_API_KEY=sk_test_placeholder STRIPE_WEBHOOK_SECRET=whsec_placeholder + +# Cloudflare Turnstile (Anti-Bot) +CLOUDFLARE_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA ``` ### 2. Frontend Environment (`frontend/.env`) @@ -54,6 +57,7 @@ Create `frontend/.env`: ```env VITE_API_URL=http://localhost:8080/api/v1 VITE_WS_URL=http://localhost:8080/ws +VITE_CLOUDFLARE_TURNSTILE_SITE_KEY=1x00000000000000000000AA ``` --- diff --git a/docs/images/preview.gif b/docs/images/preview.gif index def36c51..84132043 100644 Binary files a/docs/images/preview.gif and b/docs/images/preview.gif differ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f6818389..b888c151 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,25 +19,21 @@ export default defineConfig([ globals: globals.browser, }, rules: { - // TypeScript rules - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-empty-object-type': 'off', - '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], - // JavaScript core rules 'no-useless-assignment': 'off', 'no-prototype-builtins': 'off', 'prefer-const': 'off', 'no-empty': 'off', - // React / React Hooks rules (relax compiler preview rules) + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/refs': 'off', 'react-hooks/purity': 'off', 'react-hooks/immutability': 'off', - 'react-hooks/refs': 'off', 'react-hooks/set-state-in-effect': 'off', - 'react-hooks/rules-of-hooks': 'off', - 'react-hooks/exhaustive-deps': 'off', - 'react-hooks/incompatible-library': 'off', 'react-refresh/only-export-components': 'off', }, }, diff --git a/frontend/index.html b/frontend/index.html index a492d5d0..f8c5237c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,20 @@ + + Launchly — No-Code автоматизація Telegram-ботів та CRM платформа diff --git a/frontend/package.json b/frontend/package.json index dfa0e917..7bca61c0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,6 @@ "@icons-pack/react-simple-icons": "^13.13.0", "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.101.0", - "@tanstack/react-virtual": "^3.13.1", "@xyflow/react": "^12.11.0", "axios": "^1.17.0", "dompurify": "^3.2.4", diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index f818ab2a..72766da1 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -4,7 +4,7 @@ xmlns:xhtml="http://www.w3.org/1999/xhtml"> https://launchly.app - 2026-08-27 + 2026-09-14 weekly 1.0 @@ -13,7 +13,7 @@ https://launchly.app/blog - 2026-08-27 + 2026-09-14 daily 0.9 @@ -22,7 +22,7 @@ https://launchly.app/faq - 2026-08-27 + 2026-09-14 monthly 0.8 @@ -31,7 +31,7 @@ https://launchly.app/terms - 2026-08-27 + 2026-09-14 monthly 0.6 @@ -40,7 +40,7 @@ https://launchly.app/privacy - 2026-08-27 + 2026-09-14 monthly 0.6 @@ -49,7 +49,7 @@ https://launchly.app/acceptable-use - 2026-08-27 + 2026-09-14 monthly 0.5 @@ -58,7 +58,7 @@ https://launchly.app/ai-terms - 2026-08-27 + 2026-09-14 monthly 0.5 @@ -67,7 +67,7 @@ https://launchly.app/payment-terms - 2026-08-27 + 2026-09-14 monthly 0.5 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a30e98ff..4f33b872 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { AppRouter } from './routes'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { LanguageProvider } from './i18n/config'; import { NetworkStatusBanner } from './components/common/NetworkStatusBanner'; +import { ToastContainer } from './components/common/Toast'; import { useMultiTabSync } from './hooks/useMultiTabSync'; const AppContent: React.FC = () => { @@ -14,6 +15,7 @@ const AppContent: React.FC = () => { + diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 136f264a..01a3d5ee 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,4 +1,5 @@ import apiClient from './axios'; +import { PAGINATION } from '../const/constants'; export interface PlanDistribution { name: string; @@ -6,6 +7,16 @@ export interface PlanDistribution { color: string; } +export interface UserGrowthPoint { + date: string; + registeredCount: number; + activeCount: number; + clientsCount: number; + botsCount: number; + automationsCount: number; + messagesCount: number; +} + export interface AdminStats { totalUsers: number; totalOwners: number; @@ -22,15 +33,7 @@ export interface AdminStats { totalMessagesSentChange: string; systemUptimeSeconds: number; activeManagers: number; - userGrowth: { - date: string; - registeredCount: number; - activeCount: number; - clientsCount: number; - botsCount: number; - automationsCount: number; - messagesCount: number; - }[]; + userGrowth: UserGrowthPoint[]; botActivity: { date: string; messagesCount: number }[]; mrr: number; mrrChange: string; @@ -174,17 +177,41 @@ export interface AdminBroadcastDetail { }; } +export interface AdminBroadcastsQueryParams { + search?: string; + status?: string; + sort?: string; + page?: number; + size?: number; +} + +export interface AdminBroadcastDetailsQueryParams { + period?: string; + page?: number; + size?: number; +} + export const fetchAdminBroadcastsApi = async ( - search = '', + paramsOrSearch?: AdminBroadcastsQueryParams | string, status = 'all', sort = 'desc', page = 0, size = 10 ): Promise<{ content: AdminBroadcast[]; totalElements: number; totalPages: number }> => { - const params: Record = { page, size }; - if (search) params.search = search; - if (status) params.status = status; - if (sort) params.sort = sort; + const params: Record = {}; + if (typeof paramsOrSearch === 'object' && paramsOrSearch !== null) { + params.page = paramsOrSearch.page ?? 0; + params.size = paramsOrSearch.size ?? 10; + if (paramsOrSearch.search) params.search = paramsOrSearch.search; + if (paramsOrSearch.status) params.status = paramsOrSearch.status; + if (paramsOrSearch.sort) params.sort = paramsOrSearch.sort; + } else { + params.page = page; + params.size = size; + if (paramsOrSearch) params.search = paramsOrSearch; + if (status) params.status = status; + if (sort) params.sort = sort; + } const response = await apiClient.get<{ content: AdminBroadcast[]; totalElements: number; totalPages: number }>('/admin/broadcasts', { params }); return response.data; @@ -192,11 +219,20 @@ export const fetchAdminBroadcastsApi = async ( export const fetchAdminBroadcastDetailsApi = async ( broadcastId: number, - period = 'all', + paramsOrPeriod?: AdminBroadcastDetailsQueryParams | string, page = 0, size = 10 ): Promise => { - const params: Record = { period, page, size }; + const params: Record = {}; + if (typeof paramsOrPeriod === 'object' && paramsOrPeriod !== null) { + params.page = paramsOrPeriod.page ?? 0; + params.size = paramsOrPeriod.size ?? 10; + if (paramsOrPeriod.period) params.period = paramsOrPeriod.period; + } else { + params.page = page; + params.size = size; + if (paramsOrPeriod) params.period = paramsOrPeriod; + } const response = await apiClient.get(`/admin/broadcasts/${broadcastId}`, { params }); return response.data; }; @@ -222,35 +258,69 @@ export interface AdminLog { timestamp: string; } +export interface AdminStatsQueryParams { + search?: string; + period?: string; + startDate?: string; + endDate?: string; +} + export const fetchAdminStatsApi = async ( - search = '', + paramsOrSearch?: AdminStatsQueryParams | string, period = '', startDate = '', endDate = '' ): Promise => { const params: Record = {}; - if (search) params.search = search; - if (period) params.period = period; - if (startDate) params.startDate = startDate; - if (endDate) params.endDate = endDate; + if (typeof paramsOrSearch === 'object' && paramsOrSearch !== null) { + if (paramsOrSearch.search) params.search = paramsOrSearch.search; + if (paramsOrSearch.period) params.period = paramsOrSearch.period; + if (paramsOrSearch.startDate) params.startDate = paramsOrSearch.startDate; + if (paramsOrSearch.endDate) params.endDate = paramsOrSearch.endDate; + } else { + if (paramsOrSearch) params.search = paramsOrSearch; + if (period) params.period = period; + if (startDate) params.startDate = startDate; + if (endDate) params.endDate = endDate; + } const response = await apiClient.get('/admin/stats', { params }); return response.data; }; +export interface AdminUsersQueryParams { + search?: string; + role?: string; + plan?: string; + sort?: string; + page?: number; + size?: number; +} + export const fetchAdminUsersApi = async ( - search = '', + paramsOrSearch?: AdminUsersQueryParams | string, role = '', plan = '', sort = 'desc', - page = 0, - size = 20 + page: number = PAGINATION.DEFAULT_PAGE, + size: number = PAGINATION.DEFAULT_PAGE_SIZE ): Promise<{ content: AdminUser[]; totalElements: number; totalPages: number }> => { - const params: Record = { page, size }; - if (search) params.search = search; - if (role) params.role = role; - if (plan) params.plan = plan; - if (sort) params.sort = sort; + const params: Record = {}; + if (typeof paramsOrSearch === 'object' && paramsOrSearch !== null) { + params.page = paramsOrSearch.page ?? PAGINATION.DEFAULT_PAGE; + params.size = paramsOrSearch.size ?? PAGINATION.DEFAULT_PAGE_SIZE; + if (paramsOrSearch.search) params.search = paramsOrSearch.search; + if (paramsOrSearch.role) params.role = paramsOrSearch.role; + if (paramsOrSearch.plan) params.plan = paramsOrSearch.plan; + if (paramsOrSearch.sort) params.sort = paramsOrSearch.sort; + } else { + params.page = page; + params.size = size; + if (paramsOrSearch) params.search = paramsOrSearch; + if (role) params.role = role; + if (plan) params.plan = plan; + if (sort) params.sort = sort; + } const response = await apiClient.get<{ content: AdminUser[]; totalElements: number; totalPages: number }>('/admin/users', { params }); return response.data; @@ -266,12 +336,20 @@ export const toggleUserStatusApi = async (userId: number, blockData?: { reason: return response.data; }; -export interface fetchAdminAutomationDetailsApiParams { - automationId: number; +export interface AdminAutomationDetailsQueryParams { period?: string; page?: number; size?: number; } +export type fetchAdminAutomationDetailsApiParams = AdminAutomationDetailsQueryParams & { automationId?: number }; + +export interface AdminAutomationsQueryParams { + search?: string; + status?: string; + sort?: string; + page?: number; + size?: number; +} export interface AdminAutomationDetail { id: number; @@ -305,16 +383,26 @@ export interface AdminAutomationDetail { } export const fetchAdminAutomationsApi = async ( - search = '', + paramsOrSearch?: AdminAutomationsQueryParams | string, status = '', sort = 'desc', page = 0, size = 30 ): Promise<{ content: AdminAutomation[]; totalElements: number; totalPages: number }> => { - const params: Record = { page, size }; - if (search) params.search = search; - if (status) params.status = status; - if (sort) params.sort = sort; + const params: Record = {}; + if (typeof paramsOrSearch === 'object' && paramsOrSearch !== null) { + params.page = paramsOrSearch.page ?? 0; + params.size = paramsOrSearch.size ?? 30; + if (paramsOrSearch.search) params.search = paramsOrSearch.search; + if (paramsOrSearch.status) params.status = paramsOrSearch.status; + if (paramsOrSearch.sort) params.sort = paramsOrSearch.sort; + } else { + params.page = page; + params.size = size; + if (paramsOrSearch) params.search = paramsOrSearch; + if (status) params.status = status; + if (sort) params.sort = sort; + } const response = await apiClient.get<{ content: AdminAutomation[]; totalElements: number; totalPages: number }>('/admin/automations', { params }); return response.data; @@ -322,11 +410,20 @@ export const fetchAdminAutomationsApi = async ( export const fetchAdminAutomationDetailsApi = async ( automationId: number, - period = 'all', + paramsOrPeriod?: AdminAutomationDetailsQueryParams | string, page = 0, size = 20 ): Promise => { - const params: Record = { period, page, size }; + const params: Record = {}; + if (typeof paramsOrPeriod === 'object' && paramsOrPeriod !== null) { + params.page = paramsOrPeriod.page ?? 0; + params.size = paramsOrPeriod.size ?? 20; + if (paramsOrPeriod.period) params.period = paramsOrPeriod.period; + } else { + params.page = page; + params.size = size; + if (paramsOrPeriod) params.period = paramsOrPeriod; + } const response = await apiClient.get(`/admin/automations/${automationId}`, { params }); return response.data; }; @@ -353,8 +450,19 @@ export interface AdminLogsResponse { size: number; } +export interface AdminLogsQueryParams { + level?: string; + service?: string; + search?: string; + startDate?: string; + endDate?: string; + sort?: string; + page?: number; + size?: number; +} + export const fetchAdminLogsApi = async ( - level = '', + paramsOrLevel?: AdminLogsQueryParams | string, service = '', search = '', startDate = '', @@ -363,13 +471,26 @@ export const fetchAdminLogsApi = async ( page = 0, size = 100 ): Promise => { - const params: Record = { page, size }; - if (level) params.level = level; - if (service) params.service = service; - if (search) params.search = search; - if (startDate) params.startDate = startDate; - if (endDate) params.endDate = endDate; - if (sort) params.sort = sort; + const params: Record = {}; + if (typeof paramsOrLevel === 'object' && paramsOrLevel !== null) { + params.page = paramsOrLevel.page ?? 0; + params.size = paramsOrLevel.size ?? 100; + if (paramsOrLevel.level) params.level = paramsOrLevel.level; + if (paramsOrLevel.service) params.service = paramsOrLevel.service; + if (paramsOrLevel.search) params.search = paramsOrLevel.search; + if (paramsOrLevel.startDate) params.startDate = paramsOrLevel.startDate; + if (paramsOrLevel.endDate) params.endDate = paramsOrLevel.endDate; + if (paramsOrLevel.sort) params.sort = paramsOrLevel.sort; + } else { + params.page = page; + params.size = size; + if (paramsOrLevel) params.level = paramsOrLevel; + if (service) params.service = service; + if (search) params.search = search; + if (startDate) params.startDate = startDate; + if (endDate) params.endDate = endDate; + if (sort) params.sort = sort; + } const response = await apiClient.get('/admin/logs', { params }); const data = response.data; @@ -403,6 +524,7 @@ export interface UserAutomationSummary { active: boolean; triggerCount: number; triggerType: string; + updatedAt?: string; } export interface UserBroadcastSummary { @@ -445,14 +567,32 @@ export interface AdminUserDetail { broadcasts?: UserBroadcastSummary[]; } +export interface AdminUserDetailsQueryParams { + period?: string; + category?: string; + page?: number; + size?: number; +} + export const fetchAdminUserDetailsApi = async ( userId: number, - period = 'all', + paramsOrPeriod?: AdminUserDetailsQueryParams | string, category = 'all', page = 0, size = 20 ): Promise => { - const params: Record = { period, category, page, size }; + const params: Record = {}; + if (typeof paramsOrPeriod === 'object' && paramsOrPeriod !== null) { + params.page = paramsOrPeriod.page ?? 0; + params.size = paramsOrPeriod.size ?? 20; + if (paramsOrPeriod.period) params.period = paramsOrPeriod.period; + if (paramsOrPeriod.category) params.category = paramsOrPeriod.category; + } else { + params.page = page; + params.size = size; + if (paramsOrPeriod) params.period = paramsOrPeriod; + if (category) params.category = category; + } const response = await apiClient.get(`/admin/users/${userId}`, { params }); return response.data; }; @@ -503,14 +643,35 @@ export interface PaginatedSupportTickets { size: number; } +export interface AdminSupportTicketsQueryParams { + filter?: string; + period?: string; + search?: string; + page?: number; + size?: number; +} + export const fetchAdminSupportTicketsApi = async ( - filter = 'all', + paramsOrFilter?: AdminSupportTicketsQueryParams | string, period = 'all', search = '', page = 0, size = 50 ): Promise => { - const params: Record = { filter, period, search, page, size }; + const params: Record = {}; + if (typeof paramsOrFilter === 'object' && paramsOrFilter !== null) { + params.page = paramsOrFilter.page ?? 0; + params.size = paramsOrFilter.size ?? 50; + if (paramsOrFilter.filter) params.filter = paramsOrFilter.filter; + if (paramsOrFilter.period) params.period = paramsOrFilter.period; + if (paramsOrFilter.search) params.search = paramsOrFilter.search; + } else { + params.page = page; + params.size = size; + if (paramsOrFilter) params.filter = paramsOrFilter; + if (period) params.period = period; + if (search) params.search = search; + } const response = await apiClient.get('/admin/support-chats', { params }); return response.data; }; diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index 586d58de..c78c44bd 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -2,11 +2,39 @@ import apiClient from './axios'; import type { AiChatRequest, AiChatResponse, + AiChatSessionDetailResponse, + AiChatSessionResponse, AiSchemaRequest, AiSchemaResponse, AiUsageResponse, + CreateAiSessionRequest, + UpdateAiSessionRequest, } from '../types'; +export const getAiSessionsApi = async (): Promise => { + const response = await apiClient.get('/ai/sessions'); + return response.data; +}; + +export const getAiSessionDetailsApi = async (id: number): Promise => { + const response = await apiClient.get(`/ai/sessions/${id}`); + return response.data; +}; + +export const createAiSessionApi = async (data?: CreateAiSessionRequest): Promise => { + const response = await apiClient.post('/ai/sessions', data || {}); + return response.data; +}; + +export const updateAiSessionTitleApi = async (id: number, data: UpdateAiSessionRequest): Promise => { + const response = await apiClient.patch(`/ai/sessions/${id}`, data); + return response.data; +}; + +export const deleteAiSessionApi = async (id: number): Promise => { + await apiClient.delete(`/ai/sessions/${id}`); +}; + export const chatApi = async (data: AiChatRequest): Promise => { const response = await apiClient.post('/ai/chat', data); return response.data; diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts index 26d89c9b..b1100b05 100644 --- a/frontend/src/api/axios.test.ts +++ b/frontend/src/api/axios.test.ts @@ -3,16 +3,18 @@ import apiClient from './axios'; import { useAuthStore } from '../store/useAuthStore'; import { IDEMPOTENCY_HEADER_NAME } from '../utils/idempotency'; +import type { AxiosAdapter, InternalAxiosRequestConfig } from 'axios'; + describe('apiClient request interceptor', () => { - let originalAdapter: any; - let capturedConfig: any = null; + let originalAdapter: AxiosAdapter | AxiosAdapter[] | undefined; + let capturedConfig: InternalAxiosRequestConfig | null = null; beforeEach(() => { vi.restoreAllMocks(); useAuthStore.setState({ accessToken: null, refreshToken: null, user: null }); capturedConfig = null; - originalAdapter = apiClient.defaults.adapter; - apiClient.defaults.adapter = async (config: any) => { + originalAdapter = apiClient.defaults.adapter as unknown as AxiosAdapter | AxiosAdapter[] | undefined; + apiClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => { capturedConfig = config; return { data: { success: true }, @@ -21,7 +23,7 @@ describe('apiClient request interceptor', () => { headers: {}, config, }; - }; + }) as unknown as AxiosAdapter; }); afterEach(() => { @@ -32,7 +34,8 @@ describe('apiClient request interceptor', () => { await apiClient.post('/test-endpoint', { name: 'Launchly' }); expect(capturedConfig).not.toBeNull(); - const headers = capturedConfig.headers; + const config = capturedConfig!; + const headers = config.headers; const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) : headers?.[IDEMPOTENCY_HEADER_NAME]; @@ -46,7 +49,8 @@ describe('apiClient request interceptor', () => { await apiClient.delete('/test-endpoint/123'); expect(capturedConfig).not.toBeNull(); - const headers = capturedConfig.headers; + const config = capturedConfig!; + const headers = config.headers; const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) : headers?.[IDEMPOTENCY_HEADER_NAME]; @@ -59,7 +63,8 @@ describe('apiClient request interceptor', () => { await apiClient.patch('/test-endpoint/123', { active: true }); expect(capturedConfig).not.toBeNull(); - const headers = capturedConfig.headers; + const config = capturedConfig!; + const headers = config.headers; const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) : headers?.[IDEMPOTENCY_HEADER_NAME]; @@ -71,7 +76,8 @@ describe('apiClient request interceptor', () => { await apiClient.get('/test-endpoint'); expect(capturedConfig).not.toBeNull(); - const headers = capturedConfig.headers; + const config = capturedConfig!; + const headers = config.headers; const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) : headers?.[IDEMPOTENCY_HEADER_NAME]; @@ -87,7 +93,8 @@ describe('apiClient request interceptor', () => { }); expect(capturedConfig).not.toBeNull(); - const headers = capturedConfig.headers; + const config = capturedConfig!; + const headers = config.headers; const idempotencyKey = typeof headers?.get === 'function' ? headers.get(IDEMPOTENCY_HEADER_NAME) : headers?.[IDEMPOTENCY_HEADER_NAME]; @@ -101,7 +108,8 @@ describe('apiClient request interceptor', () => { await apiClient.get('/secure-data'); expect(capturedConfig).not.toBeNull(); - const headers = capturedConfig.headers; + const config = capturedConfig!; + const headers = config.headers; const authHeader = typeof headers?.get === 'function' ? headers.get('Authorization') : headers?.['Authorization']; @@ -113,7 +121,8 @@ describe('apiClient request interceptor', () => { await apiClient.get('/search-items', { params: { search: 'launchly' } }); expect(capturedConfig).not.toBeNull(); - expect(capturedConfig.signal).toBeDefined(); - expect(capturedConfig.signal.aborted).toBe(false); + const config = capturedConfig!; + expect(config.signal).toBeDefined(); + expect(config.signal?.aborted).toBe(false); }); }); diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 74cac831..4d0c1cf8 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -11,6 +11,9 @@ import { removePendingRequest, isRequestCanceled, } from '../utils/requestCancellation'; +import { STORAGE_KEYS } from '../const/constants'; +import { ROUTES, isPublicRoute } from '../routes/paths'; +import { toast } from '../store/useToastStore'; const apiClient = axios.create({ baseURL: '/api/v1', @@ -76,13 +79,19 @@ apiClient.interceptors.response.use( } const originalRequest = error.config; - if (error.response?.status === 403 || error.response?.data?.error === 'ACCOUNT_BLOCKED') { - const reason = error.response?.data?.reason || 'Violation of platform rules'; - localStorage.setItem('launchly_block_reason', reason); - window.location.href = '/blocked'; + const isAccountBlocked = error.response?.data?.error === 'ACCOUNT_BLOCKED'; + if (isAccountBlocked) { + const reason = error.response?.data?.reason || error.response?.data?.message || 'Violation of platform rules'; + localStorage.setItem(STORAGE_KEYS.BLOCK_REASON, reason); + window.location.href = ROUTES.BLOCKED; return Promise.reject(error); } - if (error.response?.status === 401 && !originalRequest._retry) { + if ( + error.response?.status === 401 && + originalRequest && + !originalRequest._retry && + !originalRequest.url?.includes('/auth/refresh') + ) { if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); @@ -99,17 +108,11 @@ apiClient.interceptors.response.use( if (!refreshToken) { useAuthStore.getState().logout(); const pathname = window.location.pathname; - const isPublicPage = - pathname.startsWith('/templates/install') || - pathname.startsWith('/templates/detail') || - pathname.startsWith('/blog') || - pathname.startsWith('/login') || - pathname.startsWith('/register') || - pathname === '/'; + const isPublicPage = isPublicRoute(pathname); if (!isPublicPage) { const currentUrl = pathname + window.location.search; - window.location.href = `/login?redirect=${encodeURIComponent(currentUrl)}`; + window.location.href = `${ROUTES.LOGIN}?redirect=${encodeURIComponent(currentUrl)}`; } return Promise.reject(error); } @@ -126,12 +129,23 @@ apiClient.interceptors.response.use( } catch (refreshError) { processQueue(refreshError, null); useAuthStore.getState().logout(); - window.location.href = '/login'; + window.location.href = ROUTES.LOGIN; return Promise.reject(refreshError); } finally { isRefreshing = false; } } + + const skipToast = (originalRequest as { skipToast?: boolean } | undefined)?.skipToast; + if (!skipToast && error.response?.status !== 401) { + const msg = + error.response?.data?.message || + error.response?.data?.error || + (error.code === 'ERR_NETWORK' ? 'Network error: please check your connection' : error.message) || + 'Request failed'; + toast.error(msg); + } + return Promise.reject(error); } ); diff --git a/frontend/src/api/bot.ts b/frontend/src/api/bot.ts index 7e6a88b1..c2b369e2 100644 --- a/frontend/src/api/bot.ts +++ b/frontend/src/api/bot.ts @@ -1,10 +1,13 @@ import apiClient from './axios'; +import type { Node, Edge } from '@xyflow/react'; import type { BotCreateRequest, + BotUpdateRequest, BotResponse, BotUserCreateRequest, BotUserResponse, } from '../types'; +import type { FlowSchemaResponse, BotUserUpdateRequest } from '../types/bot'; export const getBotsApi = async (): Promise => { const response = await apiClient.get('/bots'); @@ -21,7 +24,7 @@ export const createBotApi = async (data: BotCreateRequest): Promise return response.data; }; -export const updateBotApi = async (id: number, data: BotCreateRequest): Promise => { +export const updateBotApi = async (id: number, data: BotUpdateRequest): Promise => { const response = await apiClient.put(`/bots/${id}`, data); return response.data; }; @@ -30,14 +33,18 @@ export const deleteBotApi = async (id: number): Promise => { await apiClient.delete(`/bots/${id}`); }; -export const getBotSchemaApi = async (id: number): Promise => { - const response = await apiClient.get(`/bots/${id}/schema`); +export const getBotSchemaApi = async (id: number): Promise => { + const response = await apiClient.get(`/bots/${id}/schema`); return response.data; }; -export const saveBotSchemaApi = async (id: number, schemaOrNodes: any, edges?: any): Promise => { +export const saveBotSchemaApi = async ( + id: number, + schemaOrNodes: { nodes: Node[]; edges: Edge[] } | Node[] | Record[], + edges?: Edge[] | Record[] +): Promise => { const payload = edges !== undefined ? { nodes: schemaOrNodes, edges } : schemaOrNodes; - const response = await apiClient.put(`/bots/${id}/schema`, payload); + const response = await apiClient.put(`/bots/${id}/schema`, payload); return response.data; }; @@ -75,7 +82,7 @@ export const saveFlowSchemaApi = saveBotSchemaApi; export const updateBotUserApi = async ( botId: number, userId: number, - data: any + data: Partial | Record ): Promise => { const response = await apiClient.put(`/bots/${botId}/users/${userId}`, data); return response.data; @@ -85,7 +92,19 @@ export const deleteBotUserApi = async (botId: number, userId: number): Promise { +import type { CustomFieldsResponse } from '../types/customFields'; + +export interface AutomationFolder { + id: string | number; + name: string; +} + +export interface AutomationFoldersResponse { + folders?: AutomationFolder[]; + [key: string]: unknown; +} + +const parseJsonIfNeeded = (data: unknown): T => { let res = data; while (typeof res === 'string') { try { @@ -94,31 +113,109 @@ const parseJsonIfNeeded = (data: any): any => { break; } } - return res; + return res as T; }; -export const getCustomFieldsApi = async (botId: number): Promise => { - const response = await apiClient.get(`/bots/${botId}/custom-fields`); - return parseJsonIfNeeded(response.data); +export const getCustomFieldsApi = async (botId: number): Promise => { + const response = await apiClient.get(`/bots/${botId}/custom-fields`); + return parseJsonIfNeeded(response.data); }; -export const saveCustomFieldsApi = async (botId: number, data: any): Promise => { +export const saveCustomFieldsApi = async (botId: number, data: CustomFieldsResponse | unknown): Promise => { const payload = typeof data === 'string' ? data : JSON.stringify(data); - const response = await apiClient.put(`/bots/${botId}/custom-fields`, payload, { + const response = await apiClient.put(`/bots/${botId}/custom-fields`, payload, { headers: { 'Content-Type': 'application/json' }, }); - return parseJsonIfNeeded(response.data); + return parseJsonIfNeeded(response.data); }; -export const getAutomationFoldersApi = async (): Promise => { - const response = await apiClient.get('/bots/automation-folders'); - return parseJsonIfNeeded(response.data); +export const getAutomationFoldersApi = async (): Promise => { + const response = await apiClient.get('/bots/automation-folders'); + return parseJsonIfNeeded(response.data); }; -export const saveAutomationFoldersApi = async (data: any): Promise => { +export const saveAutomationFoldersApi = async (data: AutomationFoldersResponse | unknown): Promise => { const payload = typeof data === 'string' ? data : JSON.stringify(data); - const response = await apiClient.put('/bots/automation-folders', payload, { + const response = await apiClient.put('/bots/automation-folders', payload, { headers: { 'Content-Type': 'application/json' }, }); - return parseJsonIfNeeded(response.data); + return parseJsonIfNeeded(response.data); +}; + +export type MediaMode = 'ALL' | 'TEXT_ONLY' | 'MEDIA_ONLY'; +export type ViolationAction = 'DELETE_ONLY' | 'DELETE_AND_WARN' | 'DELETE_AND_MUTE' | 'DELETE_AND_KICK'; +export type CaptchaMode = 'BUTTON' | 'MATH'; + +export interface BotModerationRuleDto { + id?: number; + botId?: number; + chatId?: string; + threadId?: number | null; + enabled: boolean; + antiForwardEnabled: boolean; + antiLinkEnabled: boolean; + allowedLinks?: string; + stopWords?: string; + defaultProfanityFilter: boolean; + mediaMode: MediaMode; + actionOnViolation: ViolationAction; + warningTemplate?: string; + warnTtlSeconds?: number; + captchaEnabled?: boolean; + captchaMode?: CaptchaMode; + captchaTimeoutSeconds?: number; + captchaMessageTemplate?: string; +} + +export interface UpdateBotModerationRuleRequest { + chatId?: string; + threadId?: number | null; + enabled: boolean; + antiForwardEnabled: boolean; + antiLinkEnabled: boolean; + allowedLinks?: string; + stopWords?: string; + defaultProfanityFilter: boolean; + mediaMode: MediaMode; + actionOnViolation: ViolationAction; + warningTemplate?: string; + warnTtlSeconds?: number; + captchaEnabled?: boolean; + captchaMode?: CaptchaMode; + captchaTimeoutSeconds?: number; + captchaMessageTemplate?: string; +} + +export interface TestModerationRequest { + text?: string; + forwarded?: boolean; + hasMedia?: boolean; +} + +export interface TestModerationResponse { + violated: boolean; + reasons: string[]; + matchedStopWord?: string; +} + +export const getBotModerationSettingsApi = async (botId: number): Promise => { + const response = await apiClient.get(`/bots/${botId}/moderation`); + return response.data; }; + +export const updateBotModerationSettingsApi = async ( + botId: number, + data: UpdateBotModerationRuleRequest +): Promise => { + const response = await apiClient.put(`/bots/${botId}/moderation`, data); + return response.data; +}; + +export const testBotModerationApi = async ( + botId: number, + data: TestModerationRequest +): Promise => { + const response = await apiClient.post(`/bots/${botId}/moderation/test`, data); + return response.data; +}; + diff --git a/frontend/src/api/broadcast.ts b/frontend/src/api/broadcast.ts index 1b7a420d..5d824a46 100644 --- a/frontend/src/api/broadcast.ts +++ b/frontend/src/api/broadcast.ts @@ -54,6 +54,15 @@ export const createTagApi = async ( return response.data; }; +export const updateTagApi = async ( + botId: number, + tagId: number, + req: CreateTagRequest +): Promise => { + const response = await apiClient.put(`/broadcast/bots/${botId}/tags/${tagId}`, req); + return response.data; +}; + export const deleteTagApi = async (botId: number, tagId: number): Promise => { await apiClient.delete(`/broadcast/bots/${botId}/tags/${tagId}`); }; diff --git a/frontend/src/api/integration.ts b/frontend/src/api/integration.ts index bb3a7337..0ebf9e89 100644 --- a/frontend/src/api/integration.ts +++ b/frontend/src/api/integration.ts @@ -40,3 +40,11 @@ export const exportExcelApi = async ( }); return response.data; }; + +export const getGoogleAuthUrlApi = async (botId: number): Promise => { + const response = await apiClient.get<{ url: string }>('/integrations/google/auth-url', { + params: { botId }, + }); + return response.data.url; +}; + diff --git a/frontend/src/api/queryClient.ts b/frontend/src/api/queryClient.ts index 1959fbb0..c202f794 100644 --- a/frontend/src/api/queryClient.ts +++ b/frontend/src/api/queryClient.ts @@ -1,4 +1,5 @@ import { QueryClient } from '@tanstack/react-query'; +import { registerAuthCleanup } from '../store/authCleanup'; export const queryClient = new QueryClient({ defaultOptions: { @@ -9,3 +10,7 @@ export const queryClient = new QueryClient({ }, }, }); + +registerAuthCleanup(() => { + queryClient.clear(); +}); diff --git a/frontend/src/api/queryKeys.test.ts b/frontend/src/api/queryKeys.test.ts new file mode 100644 index 00000000..2094c72f --- /dev/null +++ b/frontend/src/api/queryKeys.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { queryKeys } from './queryKeys'; + +describe('queryKeys factory', () => { + it('generates consistent bot query keys', () => { + expect(queryKeys.bots.all).toEqual(['bots']); + expect(queryKeys.bots.detail(5)).toEqual(['bot', 5]); + expect(queryKeys.bots.schema(10)).toEqual(['bot_schema', 10]); + }); + + it('generates consistent crm query keys', () => { + expect(queryKeys.crm.conversationsRoot).toEqual(['conversations']); + expect(queryKeys.crm.conversations(7)).toEqual(['conversations', 7]); + expect(queryKeys.crm.allConversations).toEqual(['conversations', 'all']); + expect(queryKeys.crm.messages(42)).toEqual(['messages', 42]); + expect(queryKeys.crm.leadsRoot).toEqual(['leads']); + expect(queryKeys.crm.leads(3)).toEqual(['leads', 3]); + }); + + it('generates consistent admin query keys', () => { + expect(queryKeys.admin.users).toEqual(['adminUsers']); + expect(queryKeys.admin.supportTicketDetail(100)).toEqual(['adminSupportTicketDetail', 100]); + }); +}); diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts new file mode 100644 index 00000000..7e190fba --- /dev/null +++ b/frontend/src/api/queryKeys.ts @@ -0,0 +1,55 @@ +export const queryKeys = { + bots: { + all: ['bots'] as const, + detail: (botId: number) => ['bot', botId] as const, + schema: (botId: number) => ['bot_schema', botId] as const, + users: (botId: number) => ['botUsers', botId] as const, + allUsers: ['allBotUsers'] as const, + moderation: (botId: number) => ['bot_moderation', botId] as const, + }, + crm: { + all: ['crm'] as const, + conversationsRoot: ['conversations'] as const, + conversations: (botId?: number) => (botId ? (['conversations', botId] as const) : (['conversations'] as const)), + allConversations: ['conversations', 'all'] as const, + conversation: (conversationId: number) => ['conversation', conversationId] as const, + messages: (conversationId: number) => ['messages', conversationId] as const, + leadsRoot: ['leads'] as const, + leads: (botId?: number) => (botId ? (['leads', botId] as const) : (['leads'] as const)), + ordersRoot: ['orders'] as const, + orders: (botId?: number) => (botId ? (['orders', botId] as const) : (['orders'] as const)), + }, + broadcasts: { + all: ['broadcasts'] as const, + campaigns: (botId: number) => ['campaigns', botId] as const, + tagsRoot: ['tags'] as const, + tags: (botId?: number) => (botId ? (['tags', botId] as const) : (['tags'] as const)), + }, + templates: { + all: ['templates'] as const, + installed: ['installed_templates'] as const, + detail: (id: string | number) => ['template', id] as const, + }, + integrations: { + all: ['integrations'] as const, + }, + support: { + all: ['user-support-tickets'] as const, + detail: (id: string | number) => ['user-support-ticket', id] as const, + }, + stats: { + dashboard: (botId: number, days?: number) => ['dashboard-stats', botId, days] as const, + }, + admin: { + users: ['adminUsers'] as const, + userDetails: (userId?: number) => (userId ? (['adminUserDetails', userId] as const) : (['adminUserDetails'] as const)), + automations: ['adminAutomations'] as const, + automationDetails: (id?: number) => (id ? (['adminAutomationDetails', id] as const) : (['adminAutomationDetails'] as const)), + broadcasts: ['adminBroadcasts'] as const, + broadcastDetails: (id?: number) => (id ? (['adminBroadcastDetails', id] as const) : (['adminBroadcastDetails'] as const)), + supportTickets: ['adminSupportTickets'] as const, + supportTicketDetail: (id?: number | null) => (id ? (['adminSupportTicketDetail', id] as const) : (['adminSupportTicketDetail'] as const)), + logs: ['adminLogs'] as const, + stats: ['adminStats'] as const, + }, +} as const; diff --git a/frontend/src/components/admin/AdminBlockModal.tsx b/frontend/src/components/admin/AdminBlockModal.tsx new file mode 100644 index 00000000..709c5d7b --- /dev/null +++ b/frontend/src/components/admin/AdminBlockModal.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { ShieldAlert, X, Loader2 } from 'lucide-react'; +import { useTranslation } from '../../i18n/config'; + +export interface BlockReason { + code: string; + label: string; +} + +interface AdminBlockModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (reason: string, details?: string) => void; + title: string; + entityInfo: React.ReactNode; + isPending?: boolean; + reasons?: BlockReason[]; +} + +export const AdminBlockModal: React.FC = React.memo(({ + isOpen, + onClose, + onConfirm, + title, + entityInfo, + isPending = false, + reasons, +}) => { + const { t } = useTranslation(); + + const defaultReasons: BlockReason[] = [ + { code: 'SUSPICIOUS_ACTIVITY', label: t('admin.reason_suspicious') !== 'admin.reason_suspicious' ? t('admin.reason_suspicious') : 'Підозріла активність' }, + { code: 'VIOLATION_OF_RULES', label: t('admin.reason_rules') !== 'admin.reason_rules' ? t('admin.reason_rules') : 'Порушення правил' }, + { code: 'SPAM', label: t('admin.reason_spam') !== 'admin.reason_spam' ? t('admin.reason_spam') : 'Спам / зловживання' }, + { code: 'OTHER', label: t('admin.reason_other') !== 'admin.reason_other' ? t('admin.reason_other') : 'Інше' }, + ]; + + const reasonList = reasons || defaultReasons; + + const [selectedReason, setSelectedReason] = useState(reasonList[0]?.code || 'OTHER'); + const [customReason, setCustomReason] = useState(''); + + if (!isOpen) return null; + + const handleConfirm = () => { + onConfirm(selectedReason, selectedReason === 'OTHER' ? customReason : undefined); + }; + + return ( +
+
+
+

+ + {title} +

+ +
+ +
+ {entityInfo} + +
+ + {reasonList.map((r) => ( + + ))} +
+ + {selectedReason === 'OTHER' && ( +
+ +