diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml
new file mode 100644
index 0000000..6f44a35
--- /dev/null
+++ b/.github/workflows/bot.yml
@@ -0,0 +1,67 @@
+name: Bot Build
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - .github/workflows/bot.yml
+ - bot/**
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}/bot
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ packages: write
+ pull-requests: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Java 22
+ uses: actions/setup-java@v4
+ with:
+ java-version: '22'
+ distribution: 'temurin'
+ cache: maven
+
+ - name: Maven package
+ run: mvn -f bot/pom.xml clean package
+
+ - id: jacoco
+ uses: madrapps/jacoco-report@v1.6.1
+ if: github.event_name != 'workflow_dispatch'
+ with:
+ paths: bot/target/site/jacoco/jacoco.xml
+ token: ${{ secrets.GITHUB_TOKEN }}
+ min-coverage-overall: 30
+ min-coverage-changed-files: 30
+ title: Code Coverage
+ update-comment: true
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels)
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
+ - name: Build & push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: bot.Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ build-args: |
+ TELEGRAM_TOKEN=${{ secrets.TELEGRAM_TOKEN }}
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
deleted file mode 100644
index 113e9b5..0000000
--- a/.github/workflows/build.yaml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: Build
-
-on:
- workflow_dispatch:
- pull_request:
-
-jobs:
- build:
- runs-on: ubuntu-latest
- name: Build
- permissions:
- contents: read
- packages: write
- pull-requests: write
-
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-java@v4
- with:
- java-version: '23'
- distribution: 'temurin'
- cache: maven
-
- - name: maven build
- run: mvn verify
-
- - id: jacoco
- uses: madrapps/jacoco-report@v1.7.1
- if: ( github.event_name != 'workflow_dispatch' )
- with:
- paths: ${{ github.workspace }}/report/target/site/jacoco/jacoco.xml
- token: ${{ secrets.GITHUB_TOKEN }}
- min-coverage-overall: 30
- min-coverage-changed-files: 30
- title: Code Coverage
- update-comment: true
-
- linter:
- name: linter
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- pull-requests: write
-
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-java@v4
- with:
- java-version: '23'
- distribution: 'temurin'
- cache: maven
-
- - run: mvn compile -am spotless:check modernizer:modernizer spotbugs:check pmd:check pmd:cpd-check
diff --git a/.github/workflows/scrapper.yml b/.github/workflows/scrapper.yml
new file mode 100644
index 0000000..d9f679a
--- /dev/null
+++ b/.github/workflows/scrapper.yml
@@ -0,0 +1,68 @@
+name: Scrapper Build
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - .github/workflows/scrapper.yml
+ - scrapper/**
+ - link-parser/**
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}/scrapper
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ packages: write
+ pull-requests: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Java 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: maven
+
+ - name: Maven package
+ run: mvn -f scrapper/pom.xml clean package
+
+ - id: jacoco
+ uses: madrapps/jacoco-report@v1.6.1
+ if: github.event_name != 'workflow_dispatch'
+ with:
+ paths: scrapper/target/site/jacoco/jacoco.xml
+ token: ${{ secrets.GITHUB_TOKEN }}
+ min-coverage-overall: 30
+ min-coverage-changed-files: 30
+ title: Code Coverage
+ update-comment: true
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels)
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
+ - name: Build & push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: scrapper.Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ build-args: |
+ SO_TOKEN_KEY=${{ secrets.SO_TOKEN_KEY }}
diff --git a/avro-schemas/pom.xml b/avro-schemas/pom.xml
new file mode 100644
index 0000000..8d8df74
--- /dev/null
+++ b/avro-schemas/pom.xml
@@ -0,0 +1,63 @@
+
+
+ 4.0.0
+
+
+
+ backend.academy
+ root
+ 1.0
+ ../pom.xml
+
+
+ backend.academy
+ avro-schemas
+ 1.0
+ jar
+ Avro Schemas Module
+
+
+ UTF-8
+
+
+
+
+
+ org.apache.avro
+ avro
+ 1.11.3
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-pmd-plugin
+ 3.26.0
+
+ true
+
+
+
+ org.apache.avro
+ avro-maven-plugin
+ 1.11.3
+
+
+ generate-avro-sources
+
+ schema
+
+ generate-sources
+
+ ${project.basedir}/src/main/avro
+ String
+
+
+
+
+
+
+
diff --git a/avro-schemas/src/main/avro/NotificationRequest.avsc b/avro-schemas/src/main/avro/NotificationRequest.avsc
new file mode 100644
index 0000000..f909b59
--- /dev/null
+++ b/avro-schemas/src/main/avro/NotificationRequest.avsc
@@ -0,0 +1,15 @@
+{
+ "namespace": "backend.academy.avro",
+ "type": "record",
+ "name": "NotificationRequest",
+ "fields": [
+ {
+ "name": "message",
+ "type": "string"
+ },
+ {
+ "name": "userId",
+ "type": "long"
+ }
+ ]
+}
diff --git a/bot/bot.Dockerfile b/bot/bot.Dockerfile
new file mode 100644
index 0000000..b0a5a13
--- /dev/null
+++ b/bot/bot.Dockerfile
@@ -0,0 +1,14 @@
+FROM maven:3.8.8-eclipse-temurin-22 AS build
+WORKDIR /workspace
+COPY bot/pom.xml bot/pom.xml
+COPY bot/src bot/src
+RUN mvn -f bot/pom.xml clean package -DskipTests
+
+FROM eclipse-temurin:22-jre-alpine
+WORKDIR /app
+COPY --from=build /workspace/bot/target/bot.jar ./bot.jar
+
+ARG APP_TELEGRAM_TOKEN
+ENV APP_TELEGRAM_TOKEN=${APP_TELEGRAM_TOKEN}
+EXPOSE 8090
+ENTRYPOINT ["sh", "-c", "java -jar -Dapp.telegram-token=$APP_TELEGRAM_TOKEN /app/bot.jar"]
diff --git a/bot/pom.xml b/bot/pom.xml
index 064cae5..b75a614 100644
--- a/bot/pom.xml
+++ b/bot/pom.xml
@@ -1,16 +1,49 @@
-
+
4.0.0
backend.academy
root
- ${revision}
+ 1.0
+ ../pom.xml
bot
+
+ 2.18.0
+ 1.11.3
+
+
+
+ backend.academy
+ avro-schemas
+ 1.0
+
+
+ io.github.resilience4j
+ resilience4j-spring-boot2
+ 1.7.1
+
+
+ io.github.resilience4j
+ resilience4j-reactor
+ 1.7.1
+
+
+ com.bucket4j
+ bucket4j_jdk17-core
+ 8.14.0
+
+
+ org.apache.avro
+ avro
+ ${avro.version}
+ compile
+
com.github.pengrad
java-telegram-bot-api
@@ -44,10 +77,10 @@
-
-
-
-
+
+ org.springframework.kafka
+ spring-kafka
+
@@ -75,11 +108,17 @@
true
+
+
+
+
+
+
+
- org.springframework.boot
- spring-boot-devtools
- runtime
- true
+ org.springframework.cloud
+ spring-cloud-starter-contract-stub-runner
+ 4.1.2
@@ -112,11 +151,21 @@
kafka
test
-
-
-
-
-
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+ com.redis
+ testcontainers-redis
+ test
+
+
+ commons-io
+ commons-io
+
+
diff --git a/bot/src/main/java/backend/academy/bot/BotConfig.java b/bot/src/main/java/backend/academy/bot/BotConfig.java
index 63f184d..8164ee4 100644
--- a/bot/src/main/java/backend/academy/bot/BotConfig.java
+++ b/bot/src/main/java/backend/academy/bot/BotConfig.java
@@ -1,19 +1,42 @@
package backend.academy.bot;
+import backend.academy.bot.config.CircuitBreakerProps;
+import backend.academy.bot.config.HttpTimeoutProps;
+import backend.academy.bot.config.RetryProps;
import com.pengrad.telegrambot.TelegramBot;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
+import io.netty.channel.ChannelOption;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import io.netty.handler.timeout.WriteTimeoutHandler;
import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.reactive.function.client.ClientRequest;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
+import reactor.core.publisher.Mono;
+import reactor.netty.http.client.HttpClient;
+import reactor.util.retry.Retry;
@Validated
-@ConfigurationProperties(prefix = "app", ignoreUnknownFields = false)
+@ConfigurationProperties(prefix = "app", ignoreUnknownFields = true)
@EnableAsync
+@ConfigurationPropertiesScan
public record BotConfig(@NotEmpty String telegramToken) implements AsyncConfigurer {
@Bean
public TelegramBot telegramBot(BotConfig botConfig) {
@@ -21,8 +44,73 @@ public TelegramBot telegramBot(BotConfig botConfig) {
}
@Bean
- public WebClient webClient() {
- return WebClient.builder().build();
+ public CircuitBreaker circuitBreaker(CircuitBreakerProps props) {
+ CircuitBreakerConfig cfg = CircuitBreakerConfig.custom()
+ .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
+ .slidingWindowSize(props.slidingWindowSize())
+ .minimumNumberOfCalls(props.minimumRequiredCalls())
+ .failureRateThreshold(props.failureRateThreshold())
+ .permittedNumberOfCallsInHalfOpenState(props.permittedCallsInHalfOpenState())
+ .waitDurationInOpenState(props.waitDurationInOpenState())
+ .recordExceptions(Throwable.class)
+ .build();
+
+ return CircuitBreaker.of("backendServiceCb", cfg);
+ }
+
+ @Bean(name = "notificationCb")
+ public CircuitBreaker notificationCircuitBreaker(CircuitBreakerRegistry registry) {
+ return registry.circuitBreaker("notificationCb");
+ }
+
+
+ @Bean
+ public WebClient webClient(HttpTimeoutProps t, RetryProps retryProps, CircuitBreakerProps circuitBreakerProps) {
+ HttpClient http = HttpClient.create()
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS,
+ (int) t.connectTimeout().toMillis())
+ .responseTimeout(t.responseTimeout())
+ .doOnConnected(conn -> conn
+ .addHandlerLast(new ReadTimeoutHandler(
+ t.readTimeout().toMillis(), TimeUnit.MILLISECONDS))
+ .addHandlerLast(new WriteTimeoutHandler(
+ t.writeTimeout().toMillis(), TimeUnit.MILLISECONDS)));
+
+
+ ExchangeFilterFunction cbFilter = (request, next) ->
+ next.exchange(request)
+ .transformDeferred(CircuitBreakerOperator.of(circuitBreaker(circuitBreakerProps)));
+
+ ExchangeFilterFunction retryFilter = (request, next) -> next.exchange(request)
+ .flatMap(response -> {
+ if (shouldRetry(request, response, retryProps)) {
+ return response.createException()
+ .flatMap(Mono::error);
+ }
+ return Mono.just(response);
+ })
+ .retryWhen(Retry.fixedDelay(retryProps.attempts(), retryProps.backoff())
+ .filter(ex -> isRetryable(ex, retryProps)));
+
+ return WebClient.builder()
+ .clientConnector(new ReactorClientHttpConnector(http))
+ .filter(cbFilter)
+ .filter(retryFilter)
+ .build();
+ }
+
+ private boolean shouldRetry(ClientRequest req, ClientResponse resp, RetryProps rp) {
+ boolean codeMatch = rp.statusCodes().contains(resp.statusCode().value());
+ boolean methodOk = req.method() != null &&
+ List.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT,
+ HttpMethod.DELETE, HttpMethod.OPTIONS)
+ .contains(req.method());
+ return codeMatch && methodOk;
+ }
+
+ private boolean isRetryable(Throwable ex, RetryProps rp) {
+ return ex instanceof WebClientResponseException wce &&
+ rp.statusCodes().contains(wce.getRawStatusCode());
}
@Bean(name = "botTaskExecutor")
diff --git a/bot/src/main/java/backend/academy/bot/client/ScrapperClient.java b/bot/src/main/java/backend/academy/bot/client/ScrapperClient.java
index d6d77b0..ab355ac 100644
--- a/bot/src/main/java/backend/academy/bot/client/ScrapperClient.java
+++ b/bot/src/main/java/backend/academy/bot/client/ScrapperClient.java
@@ -5,8 +5,8 @@
import backend.academy.bot.model.dto.UntrackingResponse;
import backend.academy.bot.model.dto.UserRegistrationRequest;
import backend.academy.bot.model.dto.UserRegistrationResponse;
-import backend.academy.bot.model.entities.Link;
-import backend.academy.bot.services.NotificationService;
+import backend.academy.bot.model.entity.Link;
+import backend.academy.bot.service.notification.HttpNotificationService;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -21,15 +21,12 @@ public class ScrapperClient {
private static final Logger log = LoggerFactory.getLogger(ScrapperClient.class);
private final WebClient webClient;
- private final NotificationService notificationService;
private final String scrapperBaseUrl;
public ScrapperClient(
WebClient webClient,
- NotificationService notificationService,
@Value("${scrapper.base-url}") String scrapperBaseUrl) {
this.webClient = webClient;
- this.notificationService = notificationService;
this.scrapperBaseUrl = scrapperBaseUrl;
}
diff --git a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/HelpCommandHandler.java b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/HelpCommandHandler.java
index dc502bc..8cf88c6 100644
--- a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/HelpCommandHandler.java
+++ b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/HelpCommandHandler.java
@@ -1,6 +1,5 @@
package backend.academy.bot.commandHandlers.handlers;
-import backend.academy.bot.commandHandlers.CommandDispatcher;
import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.request.SendMessage;
@@ -21,10 +20,6 @@ public boolean supports(Update update) {
@Override
public void handle(Update update) {
- if (!CommandDispatcher.isIsStarted()) {
- telegramBot.execute(new SendMessage(update.message().chat().id(), "Сначала нужно зарегистрироваться."));
- return;
- }
String responseText =
"""
/start - регистрация пользователя.\n
diff --git a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/ListCommandHandler.java b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/ListCommandHandler.java
index c1c711e..3abcc79 100644
--- a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/ListCommandHandler.java
+++ b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/ListCommandHandler.java
@@ -1,7 +1,6 @@
package backend.academy.bot.commandHandlers.handlers;
-import backend.academy.bot.client.ScrapperClient;
-import backend.academy.bot.model.entities.Link;
+import backend.academy.bot.service.LinkCacheService;
import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.request.SendMessage;
@@ -14,10 +13,10 @@ public class ListCommandHandler implements CommandHandler {
private static final Logger log = LoggerFactory.getLogger(ListCommandHandler.class);
private final TelegramBot telegramBot;
- private final ScrapperClient scrapperClient;
+ private final LinkCacheService linkCacheService;
- public ListCommandHandler(ScrapperClient scrapperClient, TelegramBot telegramBot) {
- this.scrapperClient = scrapperClient;
+ public ListCommandHandler(LinkCacheService linkCacheService, TelegramBot telegramBot) {
+ this.linkCacheService = linkCacheService;
this.telegramBot = telegramBot;
}
@@ -32,45 +31,33 @@ public void handle(Update update) {
if (update.message() == null || update.message().from() == null) {
return;
}
- log.info("Обрабатываем команду /list от {}", update.message().from().id());
- long telegramId = Long.parseLong(update.message().from().id().toString());
- scrapperClient
- .getLinksByUserId(telegramId)
+ long telegramId = update.message().from().id();
+ log.info("Обрабатываем команду /list от {}", telegramId);
+
+ linkCacheService
+ .getLinks(telegramId)
.subscribe(
links -> {
if (links.isEmpty()) {
- log.info(
- "Ответ на /list: пусто для {}",
- update.message().from().id());
sendMessage(update, "Пока тут ничего нет");
} else {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < links.size(); i++) {
- Link link = links.get(i);
sb.append(i)
.append(". ")
- .append(link.getLink())
+ .append(links.get(i).getLink())
.append("\n");
}
- log.info(
- "Ответ на /list: есть данные для {}:{}",
- update.message().from().id(),
- sb);
sendMessage(update, sb.toString());
}
},
error -> {
- log.error(
- "Ошибка при получении ссылок для пользователя {}: {}",
- telegramId,
- error.getMessage());
+ log.error("Ошибка при получении ссылок для {}: {}", telegramId, error.getMessage());
sendMessage(update, "Ошибка при получении списка ссылок: " + error.getMessage());
});
}
private void sendMessage(Update update, String text) {
- Long chatId = update.message().chat().id();
- SendMessage sendMessage = new SendMessage(chatId, text);
- telegramBot.execute(sendMessage);
+ telegramBot.execute(new SendMessage(update.message().chat().id(), text));
}
}
diff --git a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/StartCommandHandler.java b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/StartCommandHandler.java
index 5a88a30..b222535 100644
--- a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/StartCommandHandler.java
+++ b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/StartCommandHandler.java
@@ -30,14 +30,14 @@ public void handle(Update update) {
.registerUser(telegramId, username)
.subscribe(
response -> {
- if (response.isExists()) {
+ sendMessage(update, "Пользователь зарегистрирован");
+ },
+ error -> {
+ if (error.getMessage().contains("уже существует")) {
sendMessage(update, "Пользователь уже зарегистрирован");
} else {
- sendMessage(update, "Пользователь зарегистрирован");
+ sendMessage(update, "Ошибка при регистрации: " + error.getMessage());
}
- },
- error -> {
- sendMessage(update, "Ошибка при регистрации: " + error.getMessage());
});
}
}
diff --git a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/TrackCommandHandler.java b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/TrackCommandHandler.java
index 3b20e34..fc6c209 100644
--- a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/TrackCommandHandler.java
+++ b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/TrackCommandHandler.java
@@ -1,6 +1,7 @@
package backend.academy.bot.commandHandlers.handlers;
import backend.academy.bot.client.ScrapperClient;
+import backend.academy.bot.service.LinkCacheService;
import backend.academy.bot.states.TrackCommandState;
import backend.academy.bot.states.TrackStateManager;
import com.pengrad.telegrambot.TelegramBot;
@@ -24,11 +25,17 @@ public class TrackCommandHandler implements CommandHandler {
private static final String TRACK_COMMAND = "/track";
private static final String SEP_REGEX = "\\s+";
private static final Logger log = LoggerFactory.getLogger(TrackCommandHandler.class);
+ private final LinkCacheService linkCacheService;
- public TrackCommandHandler(TrackStateManager stateManager, TelegramBot telegramBot, ScrapperClient scrapperClient) {
+ public TrackCommandHandler(
+ TrackStateManager stateManager,
+ TelegramBot telegramBot,
+ ScrapperClient scrapperClient,
+ LinkCacheService linkCacheService) {
this.stateManager = stateManager;
this.telegramBot = telegramBot;
this.scrapperClient = scrapperClient;
+ this.linkCacheService = linkCacheService;
}
@Override
@@ -83,6 +90,7 @@ public void handle(Update update) {
userId,
new ArrayList<>(state.getTags()),
new ArrayList<>(state.getFilters()))
+ .flatMap(unused -> linkCacheService.invalidateCache(userId))
.subscribe(
unused -> {},
error -> telegramBot.execute(new SendMessage(
diff --git a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/UntrackCommandHandler.java b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/UntrackCommandHandler.java
index 4488835..6adb249 100644
--- a/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/UntrackCommandHandler.java
+++ b/bot/src/main/java/backend/academy/bot/commandHandlers/handlers/UntrackCommandHandler.java
@@ -2,12 +2,14 @@
import backend.academy.bot.client.ScrapperClient;
import backend.academy.bot.model.dto.UntrackingResponse;
+import backend.academy.bot.service.LinkCacheService;
import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.request.SendMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
@Component
public class UntrackCommandHandler implements CommandHandler {
@@ -15,10 +17,13 @@ public class UntrackCommandHandler implements CommandHandler {
private static final Logger log = LoggerFactory.getLogger(UntrackCommandHandler.class);
private final TelegramBot telegramBot;
private final ScrapperClient scrapperClient;
+ private final LinkCacheService linkCacheService;
- public UntrackCommandHandler(TelegramBot telegramBot, ScrapperClient scrapperClient) {
+ public UntrackCommandHandler(
+ TelegramBot telegramBot, ScrapperClient scrapperClient, LinkCacheService linkCacheService) {
this.telegramBot = telegramBot;
this.scrapperClient = scrapperClient;
+ this.linkCacheService = linkCacheService;
}
@Override
@@ -45,6 +50,13 @@ public void handle(Update update) {
scrapperClient
.removeTracking(link, userId)
+ .flatMap(response -> {
+ if (response.isRemoved()) {
+ return linkCacheService.invalidateCache(userId).thenReturn(response);
+ } else {
+ return Mono.just(response);
+ }
+ })
.subscribe(
(UntrackingResponse response) -> {
if (response.isRemoved()) {
diff --git a/bot/src/main/java/backend/academy/bot/config/CircuitBreakerProps.java b/bot/src/main/java/backend/academy/bot/config/CircuitBreakerProps.java
new file mode 100644
index 0000000..ad0bdc9
--- /dev/null
+++ b/bot/src/main/java/backend/academy/bot/config/CircuitBreakerProps.java
@@ -0,0 +1,17 @@
+package backend.academy.bot.config;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import java.time.Duration;
+
+@Validated
+@ConfigurationProperties(prefix = "app.http.circuit-breaker")
+public record CircuitBreakerProps(
+ @Min(1) int slidingWindowSize,
+ @Min(1) int minimumRequiredCalls,
+ @Min(1) int failureRateThreshold,
+ @Min(1) int permittedCallsInHalfOpenState,
+ @NotNull Duration waitDurationInOpenState) { }
+
diff --git a/bot/src/main/java/backend/academy/bot/config/HttpTimeoutProps.java b/bot/src/main/java/backend/academy/bot/config/HttpTimeoutProps.java
new file mode 100644
index 0000000..4666f86
--- /dev/null
+++ b/bot/src/main/java/backend/academy/bot/config/HttpTimeoutProps.java
@@ -0,0 +1,15 @@
+package backend.academy.bot.config;
+
+import jakarta.validation.constraints.NotNull;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import java.time.Duration;
+
+@Validated
+@ConfigurationProperties(prefix = "app.http")
+public record HttpTimeoutProps(
+ @NotNull Duration connectTimeout,
+ @NotNull Duration readTimeout,
+ @NotNull Duration writeTimeout,
+ @NotNull Duration responseTimeout) { }
+
diff --git a/bot/src/main/java/backend/academy/bot/config/KafkaConfig.java b/bot/src/main/java/backend/academy/bot/config/KafkaConfig.java
new file mode 100644
index 0000000..521f37f
--- /dev/null
+++ b/bot/src/main/java/backend/academy/bot/config/KafkaConfig.java
@@ -0,0 +1,70 @@
+// src/main/java/backend/academy/bot/config/KafkaConfig.java
+package backend.academy.bot.config;
+
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.common.TopicPartition;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.config.TopicBuilder;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.listener.ContainerProperties.AckMode;
+import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
+import org.springframework.kafka.listener.DefaultErrorHandler;
+import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;
+
+@Configuration
+public class KafkaConfig {
+
+ @Value("${app.topics.input}")
+ private String inputTopic;
+
+ @Value("${app.topics.dead-letter}")
+ private String deadLetterTopic;
+
+ @Bean
+ public NewTopic inputTopic() {
+ return TopicBuilder.name(inputTopic).partitions(1).replicas(1).build();
+ }
+
+ @Bean
+ public NewTopic deadLetterTopic() {
+ return TopicBuilder.name(deadLetterTopic).partitions(1).replicas(1).build();
+ }
+
+ @Bean
+ public DeadLetterPublishingRecoverer recoverer(KafkaTemplate