From 69c4617e3d7ccdddfefb306d573e5cfca3a6ef0b Mon Sep 17 00:00:00 2001 From: ivanhromovyi Date: Wed, 14 May 2025 23:07:17 +0200 Subject: [PATCH] added MonitoringService --- .DS_Store | Bin 0 -> 6148 bytes .github/workflows/ci.yml | 6 +- Dockerfile | 4 + docker-compose.yml | 43 ++++ pom.xml | 62 +++-- .../java/ivan/solscanbot/bot/TelegramBot.java | 125 ++++++++++ .../ivan/solscanbot/config/MapperConfig.java | 4 +- .../solscanbot/config/TelegramBotConfig.java | 4 +- .../dto/external/TokenMetaResponseDto.java | 9 - .../BalanceActivitiesResponseDto.java | 2 +- .../SingleBalanceActivityResponseDto.java | 2 +- .../dto/external/meta/DataResponseDto.java | 8 + .../external/meta/MultiDataResponseDto.java | 9 + .../external/meta/TokenMetaResponseDto.java | 13 ++ .../portfolio/PortfolioWrapperDto.java | 8 + .../SingleTokenPortfolioResponseDto.java | 8 +- .../TokenPortfoliosResponseDto.java | 2 +- .../dto/external/transfer/Data.java | 10 + .../dto/external/transfer/TransferData.java | 11 + .../dto/internal/BalanceActivity.java | 23 +- .../dto/internal/MonitoredAddress.java | 10 +- .../ivan/solscanbot/dto/internal/Token.java | 17 +- .../ExceedsAmountOfAddressesException.java | 11 + .../solscanbot/mapper/ActivityMapper.java | 2 +- .../ivan/solscanbot/mapper/TokenMapper.java | 2 +- .../MonitoredAddressRepository.java | 2 + .../repository/TokenRepository.java | 2 +- .../solscanbot/service/DeFiMonitorBot.java | 185 --------------- .../solscanbot/service/MonitoringService.java | 164 ++++++++++--- .../solscanbot/service/SolScanService.java | 14 +- .../service/SolScanServiceImpl.java | 89 +++++-- .../solscanbot/service/TelegramService.java | 13 ++ .../service/TelegramServiceImpl.java | 218 ++++++++++++++++++ .../verifier/SolanaAddressVerifier.java | 25 ++ .../verifier/SolanaAddressVerifierImpl.java | 83 +++++++ src/main/resources/application.properties | 7 +- .../db/changelog/changes/create-database.yaml | 133 +++++++++++ .../db/changelog/db.changelog-master.yaml | 3 + .../SolScanBotApplicationTests.java | 4 +- src/test/resources/application.properties | 6 - 40 files changed, 1026 insertions(+), 317 deletions(-) create mode 100644 .DS_Store create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/main/java/ivan/solscanbot/bot/TelegramBot.java delete mode 100644 src/main/java/ivan/solscanbot/dto/external/TokenMetaResponseDto.java rename src/main/java/ivan/solscanbot/dto/external/{ => activity}/BalanceActivitiesResponseDto.java (83%) rename src/main/java/ivan/solscanbot/dto/external/{ => activity}/SingleBalanceActivityResponseDto.java (90%) create mode 100644 src/main/java/ivan/solscanbot/dto/external/meta/DataResponseDto.java create mode 100644 src/main/java/ivan/solscanbot/dto/external/meta/MultiDataResponseDto.java create mode 100644 src/main/java/ivan/solscanbot/dto/external/meta/TokenMetaResponseDto.java create mode 100644 src/main/java/ivan/solscanbot/dto/external/portfolio/PortfolioWrapperDto.java rename src/main/java/ivan/solscanbot/dto/external/{ => portfolio}/SingleTokenPortfolioResponseDto.java (69%) rename src/main/java/ivan/solscanbot/dto/external/{ => portfolio}/TokenPortfoliosResponseDto.java (75%) create mode 100644 src/main/java/ivan/solscanbot/dto/external/transfer/Data.java create mode 100644 src/main/java/ivan/solscanbot/dto/external/transfer/TransferData.java create mode 100644 src/main/java/ivan/solscanbot/exception/ExceedsAmountOfAddressesException.java delete mode 100644 src/main/java/ivan/solscanbot/service/DeFiMonitorBot.java create mode 100644 src/main/java/ivan/solscanbot/service/TelegramService.java create mode 100644 src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java create mode 100644 src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifier.java create mode 100644 src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java create mode 100644 src/main/resources/db/changelog/changes/create-database.yaml create mode 100644 src/main/resources/db/changelog/db.changelog-master.yaml diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..638b0d7026a296895fb31efe27e5047a6d991ff3 GIT binary patch literal 6148 zcmeHKJ8l9o5S<|it(0a9^us9E~@hB50G26hv>N@w3PC zZ21*CJ0hapdG{vLibw-Dl#2~hvwic4%`&1uIL_G1u>0B`K90x3B>U%paThYkMo#ki zoo^d7DnJFO02QDDRN&zXWQlEFAAT|qqykjn*%h$wLxCIC#4*r69T>a?0L~G1!`yob zV6gzOCXRuKz%;19plY@l8g#@<=GDY8FzBM$d}!XR*`cW4j`NGBi`GDnRDcTHEASG_ z*6RNX{!9OVPvVLSP=SA?fX@5(zQdEUwoV>rwYI?5aLc*E%`kTg1~11zFUMF|IbM2F bPDkA7K>iGvE;K6e*9zPK5X%*r literal 0 HcmV?d00001 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b94f92..83db30b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,16 +4,16 @@ on: [push] jobs: build: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '21' + java-version: '17' cache: 'maven' - name: Build with Maven diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f5d8c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-jdk-slim +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f817e04 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +version: '3.8' + +services: + postgres: + image: postgres:15 + container_name: postgres + environment: + POSTGRES_DB: $POSTGRES_DATABASE + POSTGRES_USER: $POSTGRES_USERNAME + POSTGRES_PASSWORD: $POSTGRES_PASSWORD + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - $POSTGRES_LOCAL_PORT:$POSTGRES_DOCKER_PORT + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USERNAME} -d ${POSTGRES_DATABASE}"] + interval: 5s + timeout: 5s + retries: 5 + + telegram-bot: + build: . + env_file: .env + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + ports: + - $SPRING_LOCAL_PORT:$SPRING_DOCKER_PORT + environment: + SPRING_APPLICATION_JSON: '{ + "spring.datasource.url": "jdbc:postgresql://postgres:${POSTGRES_DOCKER_PORT}/${POSTGRES_DATABASE}", + "spring.datasource.username": "${POSTGRES_USERNAME}", + "spring.datasource.password": "${POSTGRES_PASSWORD}", + "spring.datasource.driver-class-name": "org.postgresql.Driver", + "spring.jpa.properties.hibernate.dialect": "org.hibernate.dialect.PostgreSQLDialect", + "spring.liquibase.enabled": true, + "spring.liquibase.change-log": "classpath:db/changelog/db.changelog-master.yaml" + }' + JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:${DEBUG_PORT}" + +volumes: + postgres_data: \ No newline at end of file diff --git a/pom.xml b/pom.xml index 30b56d6..489db3f 100644 --- a/pom.xml +++ b/pom.xml @@ -13,22 +13,9 @@ 0.0.1-SNAPSHOT solscanbot solscanbot - - - - - - - - - - - - - checkstyle.xml - 21 + 17 1.6.3 0.2.0 @@ -42,14 +29,13 @@ spring-boot-starter-data-jpa - org.projectlombok - lombok + org.springframework.boot + spring-boot-starter - - com.h2database - h2 - runtime + org.springframework.boot + spring-boot-starter-test + test @@ -62,16 +48,42 @@ mapstruct ${org.mapstruct.version} + + org.projectlombok + lombok + provided + + + + org.springframework + spring-context + + + org.hibernate.validator + hibernate-validator + + + jakarta.validation + jakarta.validation-api + org.springframework.boot - spring-boot-starter-test - test + spring-boot-starter-jdbc - org.projectlombok - lombok - provided + org.postgresql + postgresql + runtime + + + org.liquibase + liquibase-core + + + com.h2database + h2 + runtime diff --git a/src/main/java/ivan/solscanbot/bot/TelegramBot.java b/src/main/java/ivan/solscanbot/bot/TelegramBot.java new file mode 100644 index 0000000..1cb1042 --- /dev/null +++ b/src/main/java/ivan/solscanbot/bot/TelegramBot.java @@ -0,0 +1,125 @@ +package ivan.solscanbot.bot; + +import ivan.solscanbot.exception.AddressAlreadyExistsException; +import ivan.solscanbot.exception.AddressNotMonitoredException; +import ivan.solscanbot.exception.ExceedsAmountOfAddressesException; +import ivan.solscanbot.exception.InvalidAddressException; +import ivan.solscanbot.exception.UserNotHaveAnyMonitoredAddressesException; +import ivan.solscanbot.service.TelegramService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.bots.TelegramLongPollingBot; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +@Component +public class TelegramBot extends TelegramLongPollingBot { + private final String token; + private final String username; + private final TelegramService telegramService; + + public TelegramBot( + @Value("${telegram.bot.token}") String token, + @Value("${telegram.bot.username}") String username, + TelegramService telegramService + ) { + this.token = token; + this.username = username; + this.telegramService = telegramService; + } + + @Override + public String getBotUsername() { + return username; + } + + @Override + public String getBotToken() { + return token; + } + + @Override + public void onUpdateReceived(Update update) { + if (update.hasMessage() && update.getMessage().hasText()) { + String messageText = update.getMessage().getText(); + long chatId = update.getMessage().getChatId(); + + if (messageText.startsWith("/help")) { + handleHelp(chatId); + } else if (messageText.startsWith("/add")) { + handleAddAddresses(chatId, messageText); + } else if (messageText.startsWith("/list")) { + handleListAddresses(chatId); + } else if (messageText.startsWith("/remove")) { + handleRemoveAddress(chatId, messageText); + } else if (messageText.startsWith("/portfolio")) { + handleGetPortfolios(chatId, messageText); + } else { + sendMessage( + chatId, "Unknown command. Use /help to get clarifications"); + } + } + } + + public void sendNotification(long chatId, String notification) { + sendMessage(chatId, notification); + } + + private void handleHelp(long chatId) { + String message = telegramService.getHelp(); + sendMessage(chatId, message); + } + + private void handleGetPortfolios(long chatId, String messageText) { + try { + String message = telegramService.getPortfolios(chatId, messageText); + sendMessage(chatId, message); + } catch (InvalidAddressException | AddressNotMonitoredException + | ExceedsAmountOfAddressesException e) { + sendMessage(chatId, e.getMessage()); + } + } + + private void handleAddAddresses(long chatId, String messageText) { + try { + String message = telegramService.addAddresses(chatId, messageText); + sendMessage(chatId, message); + } catch (InvalidAddressException | AddressAlreadyExistsException + | ExceedsAmountOfAddressesException e) { + sendMessage(chatId, e.getMessage()); + } + } + + private void handleListAddresses(long chatId) { + try { + String message = telegramService.listAddresses(chatId); + sendMessage(chatId, message); + } catch (UserNotHaveAnyMonitoredAddressesException e) { + sendMessage(chatId, e.getMessage()); + } + } + + private void handleRemoveAddress(long chatId, String messageText) { + try { + String message = telegramService.removeAddresses(chatId, messageText); + sendMessage(chatId, message); + } catch (InvalidAddressException | AddressNotMonitoredException + | ExceedsAmountOfAddressesException e) { + sendMessage(chatId, e.getMessage()); + } + } + + private void sendMessage(long chatId, String text) { + SendMessage message = new SendMessage(); + message.setChatId(String.valueOf(chatId)); + message.setText(text); + message.enableMarkdown(true); + message.disableWebPagePreview(); + try { + execute(message); + } catch (TelegramApiException e) { + throw new RuntimeException("Error while sending a message.", e); + } + } +} diff --git a/src/main/java/ivan/solscanbot/config/MapperConfig.java b/src/main/java/ivan/solscanbot/config/MapperConfig.java index 80e401e..159614b 100644 --- a/src/main/java/ivan/solscanbot/config/MapperConfig.java +++ b/src/main/java/ivan/solscanbot/config/MapperConfig.java @@ -7,6 +7,6 @@ componentModel = "spring", injectionStrategy = InjectionStrategy.CONSTRUCTOR, nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS, - implementationPackage = ".impl" -)public class MapperConfig { + implementationPackage = ".impl") +public class MapperConfig { } diff --git a/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java b/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java index c626ea1..45a156e 100644 --- a/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java +++ b/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java @@ -1,6 +1,6 @@ package ivan.solscanbot.config; -import ivan.solscanbot.service.DeFiMonitorBot; +import ivan.solscanbot.bot.TelegramBot; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.telegram.telegrambots.meta.TelegramBotsApi; @@ -10,7 +10,7 @@ @Configuration public class TelegramBotConfig { @Bean - public TelegramBotsApi telegramBotsApi(DeFiMonitorBot bot) throws TelegramApiException { + public TelegramBotsApi telegramBotsApi(TelegramBot bot) throws TelegramApiException { TelegramBotsApi botsApi = new TelegramBotsApi(DefaultBotSession.class); botsApi.registerBot(bot); return botsApi; diff --git a/src/main/java/ivan/solscanbot/dto/external/TokenMetaResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/TokenMetaResponseDto.java deleted file mode 100644 index 3546ee4..0000000 --- a/src/main/java/ivan/solscanbot/dto/external/TokenMetaResponseDto.java +++ /dev/null @@ -1,9 +0,0 @@ -package ivan.solscanbot.dto.external; - -import lombok.Data; - -@Data -public class TokenMetaResponseDto { - private String name; - private String symbol; -} diff --git a/src/main/java/ivan/solscanbot/dto/external/BalanceActivitiesResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/activity/BalanceActivitiesResponseDto.java similarity index 83% rename from src/main/java/ivan/solscanbot/dto/external/BalanceActivitiesResponseDto.java rename to src/main/java/ivan/solscanbot/dto/external/activity/BalanceActivitiesResponseDto.java index b727b74..08efeaa 100644 --- a/src/main/java/ivan/solscanbot/dto/external/BalanceActivitiesResponseDto.java +++ b/src/main/java/ivan/solscanbot/dto/external/activity/BalanceActivitiesResponseDto.java @@ -1,4 +1,4 @@ -package ivan.solscanbot.dto.external; +package ivan.solscanbot.dto.external.activity; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Set; diff --git a/src/main/java/ivan/solscanbot/dto/external/SingleBalanceActivityResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/activity/SingleBalanceActivityResponseDto.java similarity index 90% rename from src/main/java/ivan/solscanbot/dto/external/SingleBalanceActivityResponseDto.java rename to src/main/java/ivan/solscanbot/dto/external/activity/SingleBalanceActivityResponseDto.java index 121dab5..8d45b8d 100644 --- a/src/main/java/ivan/solscanbot/dto/external/SingleBalanceActivityResponseDto.java +++ b/src/main/java/ivan/solscanbot/dto/external/activity/SingleBalanceActivityResponseDto.java @@ -1,4 +1,4 @@ -package ivan.solscanbot.dto.external; +package ivan.solscanbot.dto.external.activity; import com.fasterxml.jackson.annotation.JsonProperty; import java.math.BigDecimal; diff --git a/src/main/java/ivan/solscanbot/dto/external/meta/DataResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/meta/DataResponseDto.java new file mode 100644 index 0000000..feb3d88 --- /dev/null +++ b/src/main/java/ivan/solscanbot/dto/external/meta/DataResponseDto.java @@ -0,0 +1,8 @@ +package ivan.solscanbot.dto.external.meta; + +import lombok.Data; + +@Data +public class DataResponseDto { + private TokenMetaResponseDto data; +} diff --git a/src/main/java/ivan/solscanbot/dto/external/meta/MultiDataResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/meta/MultiDataResponseDto.java new file mode 100644 index 0000000..838fa72 --- /dev/null +++ b/src/main/java/ivan/solscanbot/dto/external/meta/MultiDataResponseDto.java @@ -0,0 +1,9 @@ +package ivan.solscanbot.dto.external.meta; + +import java.util.Set; +import lombok.Data; + +@Data +public class MultiDataResponseDto { + private Set data; +} diff --git a/src/main/java/ivan/solscanbot/dto/external/meta/TokenMetaResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/meta/TokenMetaResponseDto.java new file mode 100644 index 0000000..690ac1c --- /dev/null +++ b/src/main/java/ivan/solscanbot/dto/external/meta/TokenMetaResponseDto.java @@ -0,0 +1,13 @@ +package ivan.solscanbot.dto.external.meta; + +import java.math.BigDecimal; +import lombok.Data; + +@Data +public class TokenMetaResponseDto { + private String name; + private String symbol; + private String address; + private int decimals; + private BigDecimal price; +} diff --git a/src/main/java/ivan/solscanbot/dto/external/portfolio/PortfolioWrapperDto.java b/src/main/java/ivan/solscanbot/dto/external/portfolio/PortfolioWrapperDto.java new file mode 100644 index 0000000..447d872 --- /dev/null +++ b/src/main/java/ivan/solscanbot/dto/external/portfolio/PortfolioWrapperDto.java @@ -0,0 +1,8 @@ +package ivan.solscanbot.dto.external.portfolio; + +import lombok.Data; + +@Data +public class PortfolioWrapperDto { + private TokenPortfoliosResponseDto data; +} diff --git a/src/main/java/ivan/solscanbot/dto/external/SingleTokenPortfolioResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java similarity index 69% rename from src/main/java/ivan/solscanbot/dto/external/SingleTokenPortfolioResponseDto.java rename to src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java index 8c9f6d4..b1bf73f 100644 --- a/src/main/java/ivan/solscanbot/dto/external/SingleTokenPortfolioResponseDto.java +++ b/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java @@ -1,4 +1,4 @@ -package ivan.solscanbot.dto.external; +package ivan.solscanbot.dto.external.portfolio; import com.fasterxml.jackson.annotation.JsonProperty; import java.math.BigDecimal; @@ -6,11 +6,11 @@ @Data public class SingleTokenPortfolioResponseDto { - @JsonProperty("token_name") - private String tokenName; + @JsonProperty("token_symbol") + private String tokenSymbol; @JsonProperty("token_address") private String tokenAddress; - @JsonProperty("token_balance") + @JsonProperty("balance") private String tokenBalance; @JsonProperty("value") private BigDecimal tokenValue; diff --git a/src/main/java/ivan/solscanbot/dto/external/TokenPortfoliosResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/portfolio/TokenPortfoliosResponseDto.java similarity index 75% rename from src/main/java/ivan/solscanbot/dto/external/TokenPortfoliosResponseDto.java rename to src/main/java/ivan/solscanbot/dto/external/portfolio/TokenPortfoliosResponseDto.java index 1517eda..e04fb51 100644 --- a/src/main/java/ivan/solscanbot/dto/external/TokenPortfoliosResponseDto.java +++ b/src/main/java/ivan/solscanbot/dto/external/portfolio/TokenPortfoliosResponseDto.java @@ -1,4 +1,4 @@ -package ivan.solscanbot.dto.external; +package ivan.solscanbot.dto.external.portfolio; import java.util.Set; import lombok.Data; diff --git a/src/main/java/ivan/solscanbot/dto/external/transfer/Data.java b/src/main/java/ivan/solscanbot/dto/external/transfer/Data.java new file mode 100644 index 0000000..cba6426 --- /dev/null +++ b/src/main/java/ivan/solscanbot/dto/external/transfer/Data.java @@ -0,0 +1,10 @@ +package ivan.solscanbot.dto.external.transfer; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@lombok.Data +public class Data { + @JsonProperty("data") + private List data; +} diff --git a/src/main/java/ivan/solscanbot/dto/external/transfer/TransferData.java b/src/main/java/ivan/solscanbot/dto/external/transfer/TransferData.java new file mode 100644 index 0000000..1764afe --- /dev/null +++ b/src/main/java/ivan/solscanbot/dto/external/transfer/TransferData.java @@ -0,0 +1,11 @@ +package ivan.solscanbot.dto.external.transfer; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Date; +import lombok.Data; + +@Data +public class TransferData { + @JsonProperty("time") + private Date time; +} diff --git a/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java b/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java index 9e7bb1e..372e765 100644 --- a/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java +++ b/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java @@ -5,36 +5,47 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import java.math.BigDecimal; import java.util.Date; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import lombok.ToString; import lombok.experimental.Accessors; import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; +import org.hibernate.annotations.Where; @Data @Entity @NoArgsConstructor @Accessors(chain = true) -@SQLDelete(sql = "UPDATE roles SET is_deleted = true WHERE id = ?") -@SQLRestriction("is_deleted = false") +@SQLDelete(sql = "UPDATE balance_activities SET is_deleted = true WHERE id = ?") +@Where(clause = "is_deleted = false") +@Table(name = "balance_activities") public class BalanceActivity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) - private String address; @Column(nullable = false, name = "token_address") private String tokenAddress; - @Column(nullable = false, name = "token_name") + @Column(name = "token_name") private String tokenName; @Column(nullable = false, name = "token_symbol") private String tokenSymbol; + @Column(nullable = false, name = "value_in_usd") + private BigDecimal valueInUsd; @Column(nullable = false) private BigDecimal amount; @Column(nullable = false) private Date time; + @ManyToOne + @ToString.Exclude + @EqualsAndHashCode.Exclude + @JoinColumn(nullable = false, name = "address_id") + private MonitoredAddress monitoredAddress; @Column(nullable = false, name = "is_deleted") private boolean isDeleted = false; } diff --git a/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java b/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java index efb1df9..e081a19 100644 --- a/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java +++ b/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java @@ -8,6 +8,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; import java.util.HashSet; import java.util.Set; import lombok.Data; @@ -15,13 +16,14 @@ import lombok.NoArgsConstructor; import lombok.ToString; import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; +import org.hibernate.annotations.Where; -@Entity @Data +@Entity @NoArgsConstructor -@SQLDelete(sql = "UPDATE roles SET is_deleted = true WHERE id = ?") -@SQLRestriction("is_deleted = false") +@SQLDelete(sql = "UPDATE monitored_addresses SET is_deleted = true WHERE id = ?") +@Where(clause = "is_deleted = false") +@Table(name = "monitored_addresses") public class MonitoredAddress { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/ivan/solscanbot/dto/internal/Token.java b/src/main/java/ivan/solscanbot/dto/internal/Token.java index 3611a6b..133d5af 100644 --- a/src/main/java/ivan/solscanbot/dto/internal/Token.java +++ b/src/main/java/ivan/solscanbot/dto/internal/Token.java @@ -5,29 +5,26 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import java.math.BigDecimal; +import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; +import org.hibernate.annotations.Where; @Data @Entity @NoArgsConstructor -@SQLDelete(sql = "UPDATE roles SET is_deleted = true WHERE id = ?") -@SQLRestriction("is_deleted = false") +@SQLDelete(sql = "UPDATE tokens SET is_deleted = true WHERE id = ?") +@Where(clause = "is_deleted = false") +@Table(name = "tokens") public class Token { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(unique = true, nullable = false, name = "token_name") - private String tokenName; + @Column(name = "token_symbol") + private String tokenSymbol; @Column(unique = true, nullable = false, name = "token_address") private String tokenAddress; - @Column(unique = true, nullable = false, name = "token_balance") - private String tokenBalance; - @Column(unique = true, nullable = false, name = "token_value") - private BigDecimal tokenValue; @Column(name = "is_deleted") private boolean isDeleted = false; } diff --git a/src/main/java/ivan/solscanbot/exception/ExceedsAmountOfAddressesException.java b/src/main/java/ivan/solscanbot/exception/ExceedsAmountOfAddressesException.java new file mode 100644 index 0000000..c49c4e8 --- /dev/null +++ b/src/main/java/ivan/solscanbot/exception/ExceedsAmountOfAddressesException.java @@ -0,0 +1,11 @@ +package ivan.solscanbot.exception; + +public class ExceedsAmountOfAddressesException extends RuntimeException { + public ExceedsAmountOfAddressesException(String message) { + super(message); + } + + public ExceedsAmountOfAddressesException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java b/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java index 87c1280..ba03f4f 100644 --- a/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java +++ b/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java @@ -1,7 +1,7 @@ package ivan.solscanbot.mapper; import ivan.solscanbot.config.MapperConfig; -import ivan.solscanbot.dto.external.SingleBalanceActivityResponseDto; +import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto; import ivan.solscanbot.dto.internal.BalanceActivity; import org.mapstruct.Mapper; diff --git a/src/main/java/ivan/solscanbot/mapper/TokenMapper.java b/src/main/java/ivan/solscanbot/mapper/TokenMapper.java index c199ff2..2e73407 100644 --- a/src/main/java/ivan/solscanbot/mapper/TokenMapper.java +++ b/src/main/java/ivan/solscanbot/mapper/TokenMapper.java @@ -1,7 +1,7 @@ package ivan.solscanbot.mapper; import ivan.solscanbot.config.MapperConfig; -import ivan.solscanbot.dto.external.SingleTokenPortfolioResponseDto; +import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto; import ivan.solscanbot.dto.internal.Token; import org.mapstruct.Mapper; diff --git a/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java b/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java index fe3d6e6..6dc522f 100644 --- a/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java +++ b/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java @@ -3,6 +3,7 @@ import ivan.solscanbot.dto.internal.MonitoredAddress; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface MonitoredAddressRepository @@ -14,5 +15,6 @@ public interface MonitoredAddressRepository List findByChatId(Long chatId); + @EntityGraph(attributePaths = "tokens") Optional findByAddress(String address); } diff --git a/src/main/java/ivan/solscanbot/repository/TokenRepository.java b/src/main/java/ivan/solscanbot/repository/TokenRepository.java index b16fd25..7711686 100644 --- a/src/main/java/ivan/solscanbot/repository/TokenRepository.java +++ b/src/main/java/ivan/solscanbot/repository/TokenRepository.java @@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TokenRepository extends JpaRepository { - Optional findByTokenName(String tokenName); + Optional findByTokenAddress(String tokenAddress); } diff --git a/src/main/java/ivan/solscanbot/service/DeFiMonitorBot.java b/src/main/java/ivan/solscanbot/service/DeFiMonitorBot.java deleted file mode 100644 index 7efb014..0000000 --- a/src/main/java/ivan/solscanbot/service/DeFiMonitorBot.java +++ /dev/null @@ -1,185 +0,0 @@ -package ivan.solscanbot.service; - -import ivan.solscanbot.dto.internal.MonitoredAddress; -import ivan.solscanbot.dto.internal.Token; -import ivan.solscanbot.exception.AddressAlreadyExistsException; -import ivan.solscanbot.exception.AddressNotMonitoredException; -import ivan.solscanbot.exception.InvalidAddressException; -import ivan.solscanbot.exception.UserNotHaveAnyMonitoredAddressesException; -import ivan.solscanbot.mapper.TokenMapper; -import ivan.solscanbot.repository.MonitoredAddressRepository; -import ivan.solscanbot.repository.TokenRepository; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.telegram.telegrambots.bots.TelegramLongPollingBot; -import org.telegram.telegrambots.meta.api.methods.send.SendMessage; -import org.telegram.telegrambots.meta.api.objects.Update; -import org.telegram.telegrambots.meta.exceptions.TelegramApiException; - -@Component -public class DeFiMonitorBot extends TelegramLongPollingBot { - private static final String SOLANA_ADDRESS_PATTERN = "[1-9A-HJ-NP-Za-km-z]{32,44}"; - - private final String token; - private final String username; - private final MonitoredAddressRepository addressRepository; - private final TokenRepository tokenRepository; - private final SolScanServiceImpl solScanService; - private final TokenMapper tokenMapper; - - public DeFiMonitorBot( - @Value("${telegram.bot.token}") String token, - @Value("${telegram.bot.username}") String username, - MonitoredAddressRepository addressRepository, TokenRepository tokenRepository, - SolScanServiceImpl solScanService, TokenMapper tokenMapper - ) { - this.token = token; - this.username = username; - this.addressRepository = addressRepository; - this.tokenRepository = tokenRepository; - this.solScanService = solScanService; - this.tokenMapper = tokenMapper; - } - - @Override - public String getBotUsername() { - return username; - } - - @Override - public String getBotToken() { - return token; - } - - @Override - public void onUpdateReceived(Update update) { - if (update.hasMessage() && update.getMessage().hasText()) { - String messageText = update.getMessage().getText(); - long chatId = update.getMessage().getChatId(); - - if (messageText.startsWith("/add")) { - handleAddAddress(chatId, messageText); - } else if (messageText.startsWith("/list")) { - handleListAddresses(chatId); - } else if (messageText.startsWith("/remove")) { - handleRemoveAddress(chatId, messageText); - } else if (messageText.startsWith("/portfolio")) { - handleAddressPortfolio(chatId, messageText); - } else { - sendMessage( - chatId, "Unknown command. Use /add, /list, or /remove"); - } - } - } - - public void sendMessage(long chatId, String text) { - SendMessage message = new SendMessage(); - message.setChatId(String.valueOf(chatId)); - message.setText(text); - try { - execute(message); - } catch (TelegramApiException e) { - throw new RuntimeException("Error while sending a message.", e); - } - } - - private void handleAddressPortfolio(long chatId, String messageText) { - String address = getSolanaAddressFromMessage(messageText); - verifySolanaAddress(chatId, address); - verifyUserHasCertainAddress(chatId, address); - - MonitoredAddress solanaAddress = addressRepository.findByAddress(address) - .orElseThrow(() -> new AddressNotMonitoredException(address)); - - String tokens = Optional.ofNullable(solanaAddress.getTokens()) - .orElse(Collections.emptySet()) - .stream() - .map(Token::getTokenName) - .collect(Collectors.joining(", ")); - - sendMessage(chatId, "Tokens in portfolio:\n" + tokens); - } - - public void handleAddAddress(long chatId, String message) { - String solanaAddress = getSolanaAddressFromMessage(message); - verifySolanaAddress(chatId, solanaAddress); - verifyAddressAlreadyAdded(chatId, solanaAddress); - Set tokensFromSolScan = solScanService.getTokensByAddress(solanaAddress) - .stream() - .map(tokenMapper::toModel) - .collect(Collectors.toSet()); - Set managedTokens = tokensFromSolScan.stream() - .map(t -> tokenRepository.findByTokenName(t.getTokenName()) - .orElseGet(() -> tokenRepository.save(t))) - .collect(Collectors.toSet()); - MonitoredAddress address = new MonitoredAddress(); - address.setAddress(solanaAddress); - address.setChatId(chatId); - address.setTokens(managedTokens); - addressRepository.save(address); - sendMessage(chatId, "address\n" + solanaAddress + "\nis added to monitoring"); - } - - private void handleListAddresses(long chatId) { - List addresses = addressRepository.findByChatId(chatId); - verifyUserHasAddresses(chatId, addresses); - AtomicInteger count = new AtomicInteger(1); - String addressList = addresses - .stream() - .map(adr -> count.getAndIncrement() + ". " + adr.getAddress()) - .collect(Collectors.joining("\n")); - sendMessage(chatId, "List of monitored addresses:\n" + addressList); - } - - private void handleRemoveAddress(long chatId, String message) { - String solanaAddress = getSolanaAddressFromMessage(message); - verifySolanaAddress(chatId, solanaAddress); - addressRepository.deleteByAddressAndChatId(solanaAddress, chatId); - sendMessage( - chatId, "address\n" + solanaAddress + "\nis removed from monitoring"); - } - - public void sendNotification(long chatId, String notification) { - sendMessage(chatId, notification); - } - - private String getSolanaAddressFromMessage(String message) { - return message.substring(4).trim(); - } - - private void verifySolanaAddress(long chatId, String address) { - if (address == null || !address.matches(SOLANA_ADDRESS_PATTERN)) { - sendMessage(chatId, "Invalid Solana Address"); - throw new InvalidAddressException("Invalid Solana Address"); - } - } - - private void verifyAddressAlreadyAdded(long chatId, String address) { - if (addressRepository.existsByAddressAndChatId(address, chatId)) { - sendMessage(chatId,"You already have this address in your list"); - throw new AddressAlreadyExistsException("You already have this address in your list"); - } - } - - private void verifyUserHasAddresses(long chatId, List addresses) { - if (addresses.isEmpty()) { - sendMessage(chatId, "You have no monitored addresses"); - throw new UserNotHaveAnyMonitoredAddressesException("You have no monitored addresses"); - } - } - - private void verifyUserHasCertainAddress(long chatId, String address) { - if (!addressRepository.existsByAddressAndChatId(address, chatId)) { - sendMessage(chatId, - "You need to add this address to monitored address first '/add your_address'"); - throw new AddressNotMonitoredException( - "You need to add this address to monitored address first '/add your_address'"); - } - } -} diff --git a/src/main/java/ivan/solscanbot/service/MonitoringService.java b/src/main/java/ivan/solscanbot/service/MonitoringService.java index 5f39bd7..cce99c9 100644 --- a/src/main/java/ivan/solscanbot/service/MonitoringService.java +++ b/src/main/java/ivan/solscanbot/service/MonitoringService.java @@ -1,62 +1,162 @@ package ivan.solscanbot.service; -import ivan.solscanbot.dto.external.TokenMetaResponseDto; +import com.google.common.util.concurrent.RateLimiter; +import ivan.solscanbot.bot.TelegramBot; +import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto; +import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto; import ivan.solscanbot.dto.internal.BalanceActivity; import ivan.solscanbot.dto.internal.MonitoredAddress; import ivan.solscanbot.mapper.ActivityMapper; import ivan.solscanbot.repository.ActivityRepository; import ivan.solscanbot.repository.MonitoredAddressRepository; import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.jvnet.hk2.annotations.Service; +import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class MonitoringService { + private static final int CHUNK_SIZE = 20; + private static final String SOLSCAN_ACCOUNT_URL = "https://solscan.io/account/"; + private static final String SOLSCAN_TOKEN_URL = "https://solscan.io/token/"; + private static final String FIRST_TRACKING_ADDRESS = + "APZmQqyytWLMFioMsskqhWrGJCd9Fpo7L2f2YhdpSe6U"; + private final MonitoredAddressRepository addressRepository; - private final DeFiMonitorBot deFiMonitorBot; + private final TelegramBot telegramBot; private final SolScanServiceImpl solScanService; private final ActivityRepository activityRepository; private final ActivityMapper activityMapper; - @Scheduled(fixedRate = 15000) - public void sendTokenList() { - for (MonitoredAddress address : addressRepository.findAll()) { + @Scheduled(fixedDelay = 300000) + public void newActivityFound() { + RateLimiter rateLimiter = RateLimiter.create(5.0); + addressRepository.findAll().forEach(address -> { try { - Set activities = - solScanService.getNewBalanceActivities(address.getAddress()) - .stream() - .filter(a -> BigDecimal.ZERO.equals(a.getPreBalance())) - .map(activityMapper::toModel) - .collect(Collectors.toSet()); + rateLimiter.acquire(); + Set activities = fetchAndProcessActivities(address); if (!activities.isEmpty()) { - StringBuilder notification = new StringBuilder(); - notification.append("New activity for Solana address\n") - .append(address.getAddress()) - .append("was found"); - int count = 1; - for (BalanceActivity activity : activities) { - TokenMetaResponseDto meta = - solScanService.getTokenMetaFromAddress(address.getAddress()); - activity.setTokenName(meta.getName()) - .setTokenSymbol(meta.getSymbol()); - notification.append("\n").append(count++) - .append(". token address: ").append(activity.getTokenAddress()) - .append("\n") - .append(". token address: ").append(activity.getTokenAddress()) - .append("\n") - .append("token amount: ").append(activity.getAmount()).append("\n") - .append("time: ").append(activity.getTime()).append("\n"); - } activityRepository.saveAll(activities); - deFiMonitorBot.sendNotification(address.getChatId(), notification.toString()); + sendTelegramNotification(address, activities); } } catch (Exception e) { - throw new RuntimeException(e); + log.error("Error checking activities for address {}", address.getAddress(), e); + } + }); + } + + private Set fetchAndProcessActivities(MonitoredAddress address) { + Set newActivities = + solScanService.getNewBalanceActivities(address.getAddress()) + .stream() + .filter(act -> BigDecimal.ZERO.equals(act.getPreBalance())) + .collect(Collectors.toSet()); + + List tokenAddresses = newActivities.stream() + .map(SingleBalanceActivityResponseDto::getTokenAddress) + .toList(); + + Map metaMap = batchFetchTokenMetadata(tokenAddresses); + + return newActivities.stream() + .map(activityMapper::toModel) + .map(act -> enrichWithTokenMeta(act, metaMap.get(act.getTokenAddress()))) + .filter(tok -> tok.getValueInUsd().compareTo(BigDecimal.valueOf(100)) > 0) + .peek(act -> act.setMonitoredAddress(address)) + .collect(Collectors.toSet()); + } + + private Map batchFetchTokenMetadata(List tokenAddresses) { + Map metaMap = new ConcurrentHashMap<>(); + List addressList = new ArrayList<>(tokenAddresses); + for (int i = 0; i < addressList.size(); i += CHUNK_SIZE) { + List chunk = addressList.subList( + i, Math.min(i + CHUNK_SIZE, addressList.size())); + try { + Map chunkResults = + solScanService.getMetaMapFromAddresses(chunk); + metaMap.putAll(chunkResults); + } catch (Exception e) { + log.error("Failed to fetch batch metadata for chunk: {}", chunk, e); + fetchTokenMetadata(chunk, metaMap); } } + return metaMap; } + + private void fetchTokenMetadata(List tokenAddresses, + Map metaMap) { + tokenAddresses.forEach(address -> { + TokenMetaResponseDto meta = solScanService.getTokenMeta(address); + metaMap.put(address, meta); + }); + } + + private BalanceActivity enrichWithTokenMeta(BalanceActivity act, TokenMetaResponseDto meta) { + BigDecimal price = Optional.ofNullable(meta.getPrice()).orElse(BigDecimal.ZERO); + int decimals = meta.getDecimals() > 0 ? meta.getDecimals() : 9; + BigDecimal normalizedAmount = act.getAmount() + .divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL32); + act.setValueInUsd(normalizedAmount.multiply(price)) + .setTokenName(meta.getName()) + .setTokenSymbol(meta.getSymbol()); + return act; + } + + private void sendTelegramNotification(MonitoredAddress address, + Set activities) { + AtomicInteger count = new AtomicInteger(1); + String tokens = activities.stream() + .map(act -> String.format( + "%d. Token: %s\nUSD Value: $%s\nToken link: [%s](%s#balanceChanges)\n", + count.getAndIncrement(), + act.getTokenName(), + act.getValueInUsd().setScale(2, RoundingMode.HALF_UP), + shortenAddress(act.getTokenAddress()), + SOLSCAN_TOKEN_URL + act.getTokenAddress() + )) + .collect(Collectors.joining()); + String message = String.format("New activity for address: [%s](%s#balanceChanges)\n%s", + shortenAddress(address.getAddress()), + SOLSCAN_ACCOUNT_URL + address.getAddress(), + tokens); + telegramBot.sendNotification(address.getChatId(), message); + } + + private String shortenAddress(String address) { + return address.length() > 8 + ? address.substring(0, 4) + "..." + address.substring(address.length() - 4) + : address; + } + + /*public void monitorAddress() { + try { + if (solScanService.newTokenTransfer(FIRST_TRACKING_ADDRESS)) { + Set ids = + addressRepository.findAll() + .stream() + .map(MonitoredAddress::getChatId) + .collect(Collectors.toSet()); + log.info("New transfer detected, sending notification"); + for (Long id : ids) { + telegramBot.sendNotification(id, "!!!"); + } + } + } catch (Exception e) { + log.error("Error in scheduled task", e); + } + }*/ } diff --git a/src/main/java/ivan/solscanbot/service/SolScanService.java b/src/main/java/ivan/solscanbot/service/SolScanService.java index 28bb6a3..3d1b090 100644 --- a/src/main/java/ivan/solscanbot/service/SolScanService.java +++ b/src/main/java/ivan/solscanbot/service/SolScanService.java @@ -1,8 +1,10 @@ package ivan.solscanbot.service; -import ivan.solscanbot.dto.external.SingleBalanceActivityResponseDto; -import ivan.solscanbot.dto.external.SingleTokenPortfolioResponseDto; -import ivan.solscanbot.dto.external.TokenMetaResponseDto; +import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto; +import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto; +import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto; +import java.util.List; +import java.util.Map; import java.util.Set; public interface SolScanService { @@ -10,5 +12,9 @@ public interface SolScanService { Set getNewBalanceActivities(String address); - TokenMetaResponseDto getTokenMetaFromAddress(String address); + Map getMetaMapFromAddresses(List addresses); + + TokenMetaResponseDto getTokenMeta(String address); + + boolean newTokenTransfer(String tokenAddress); } diff --git a/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java b/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java index 46aaa65..2612537 100644 --- a/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java +++ b/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java @@ -1,17 +1,26 @@ package ivan.solscanbot.service; import com.fasterxml.jackson.databind.ObjectMapper; -import ivan.solscanbot.dto.external.BalanceActivitiesResponseDto; -import ivan.solscanbot.dto.external.SingleBalanceActivityResponseDto; -import ivan.solscanbot.dto.external.SingleTokenPortfolioResponseDto; -import ivan.solscanbot.dto.external.TokenMetaResponseDto; -import ivan.solscanbot.dto.external.TokenPortfoliosResponseDto; +import ivan.solscanbot.dto.external.activity.BalanceActivitiesResponseDto; +import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto; +import ivan.solscanbot.dto.external.meta.DataResponseDto; +import ivan.solscanbot.dto.external.meta.MultiDataResponseDto; +import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto; +import ivan.solscanbot.dto.external.portfolio.PortfolioWrapperDto; +import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto; +import ivan.solscanbot.dto.external.transfer.Data; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -23,8 +32,13 @@ public class SolScanServiceImpl implements SolScanService { "https://pro-api.solscan.io/v2.0/account/portfolio"; private static final String SOL_SCAN_ACTIVITIES_URL = "https://pro-api.solscan.io/v2.0/account/balance_change"; + private static final String SOL_SCAN_TOKEN_MULTI_URL = + "https://pro-api.solscan.io/v2.0/token/meta/multi"; private static final String SOL_SCAN_TOKEN_URL = "https://pro-api.solscan.io/v2.0/token/meta"; + private static final String SOL_SCAN_TOKEN_TRANSFERS = + "https://pro-api.solscan.io/v2.0/token/transfer"; + @Value("${sol.scan.key}") private String solScanKey; private final ObjectMapper objectMapper; @@ -42,9 +56,9 @@ public Set getTokensByAddress(String address) { try { HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); - TokenPortfoliosResponseDto tokens = - objectMapper.readValue(response.body(), TokenPortfoliosResponseDto.class); - return tokens.getTokens(); + PortfolioWrapperDto wrapper = + objectMapper.readValue(response.body(), PortfolioWrapperDto.class); + return wrapper.getData().getTokens(); } catch (Exception e) { throw new RuntimeException("An error occurred. Please try again later."); } @@ -53,7 +67,7 @@ public Set getTokensByAddress(String address) { @Override public Set getNewBalanceActivities(String address) { HttpClient httpClient = HttpClient.newHttpClient(); - long fromTime = Instant.now().getEpochSecond() - 60; + long fromTime = Instant.now().getEpochSecond() - 300; String url = SOL_SCAN_ACTIVITIES_URL + "?address=" + address + "&from_time=" + fromTime; HttpRequest httpRequest = HttpRequest.newBuilder() @@ -74,8 +88,9 @@ public Set getNewBalanceActivities(String addr } @Override - public TokenMetaResponseDto getTokenMetaFromAddress(String tokenAddress) { - String apiUrl = SOL_SCAN_TOKEN_URL + tokenAddress; + public Map getMetaMapFromAddresses(List tokenAddresses) { + String apiUrl = SOL_SCAN_TOKEN_MULTI_URL + "?address[]=" + + String.join("&address[]=", tokenAddresses); HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(apiUrl)) @@ -85,9 +100,57 @@ public TokenMetaResponseDto getTokenMetaFromAddress(String tokenAddress) { try { HttpResponse response = client.send( request, HttpResponse.BodyHandlers.ofString()); - return objectMapper.readValue(response.body(), TokenMetaResponseDto.class); + MultiDataResponseDto data = objectMapper + .readValue(response.body(), MultiDataResponseDto.class); + return data.getData().stream() + .collect(Collectors.toMap( + TokenMetaResponseDto::getAddress, + Function.identity(), + (existing, replacement) -> existing + )); + } catch (Exception e) { + throw new RuntimeException("Failed to get multi token data. Please try again later."); + } + } + + @Override + public TokenMetaResponseDto getTokenMeta(String address) { + String apiUrl = SOL_SCAN_TOKEN_URL + "?address=" + address; + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .header("accept", "application/json") + .header("token", solScanKey) + .build(); + try { + HttpResponse response = + client.send(request, HttpResponse.BodyHandlers.ofString()); + DataResponseDto data = + objectMapper.readValue(response.body(), DataResponseDto.class); + return data.getData(); + } catch (Exception e) { + throw new RuntimeException("Failed to get token data. Please try again later."); + } + } + + @Override + public boolean newTokenTransfer(String tokenAddress) { + String apiUrl = SOL_SCAN_TOKEN_TRANSFERS + "?address=" + tokenAddress; + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .header("accept", "application/json") + .header("token", solScanKey) + .build(); + try { + HttpResponse response = + client.send(request, HttpResponse.BodyHandlers.ofString()); + Data result = objectMapper.readValue(response.body(), Data.class); + return result.getData().stream() + .anyMatch(transfer -> transfer.getTime() + .after(Date.from(Instant.now().minus(1, ChronoUnit.MINUTES)))); } catch (Exception e) { - throw new RuntimeException("Failed to get token name. Please try again later."); + throw new RuntimeException(e); } } } diff --git a/src/main/java/ivan/solscanbot/service/TelegramService.java b/src/main/java/ivan/solscanbot/service/TelegramService.java new file mode 100644 index 0000000..3658437 --- /dev/null +++ b/src/main/java/ivan/solscanbot/service/TelegramService.java @@ -0,0 +1,13 @@ +package ivan.solscanbot.service; + +public interface TelegramService { + String getHelp(); + + String getPortfolios(long chatId, String messageText); + + String addAddresses(long chatId, String message); + + String listAddresses(long chatId); + + String removeAddresses(long chatId, String message); +} diff --git a/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java b/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java new file mode 100644 index 0000000..1571f3d --- /dev/null +++ b/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java @@ -0,0 +1,218 @@ +package ivan.solscanbot.service; + +import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto; +import ivan.solscanbot.dto.internal.MonitoredAddress; +import ivan.solscanbot.dto.internal.Token; +import ivan.solscanbot.mapper.TokenMapper; +import ivan.solscanbot.repository.MonitoredAddressRepository; +import ivan.solscanbot.repository.TokenRepository; +import ivan.solscanbot.verifier.SolanaAddressVerifier; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TelegramServiceImpl implements TelegramService { + private static final String SOLSCAN_ACCOUNT_URL = "https://solscan.io/account/"; + private static final String SOLSCAN_TOKEN_URL = "https://solscan.io/token/"; + private static final int AMOUNT_OF_ADDRESSES = 3; + + private final MonitoredAddressRepository addressRepository; + private final TokenRepository tokenRepository; + private final SolScanServiceImpl solScanService; + private final TokenMapper tokenMapper; + private final SolanaAddressVerifier addressVerifier; + + @Override + public String getHelp() { + return getHelpMessage(); + } + + @Override + public String getPortfolios(long chatId, String message) { + Set addresses = getSolanaAddressesFromMessage(AMOUNT_OF_ADDRESSES, message); + addressVerifier.verifyUserHasCertainAddresses(chatId, addresses); + + List monitoredAddresses = addresses.stream() + .map(addressRepository::findByAddress) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + String formattedAddresses = monitoredAddresses.stream() + .map(this::formatSingleMonitoredAddress) + .collect(Collectors.joining("\n\n")); + return String.format("Tokens in portfolio:\n%s", formattedAddresses); + } + + @Override + public String addAddresses(long chatId, String message) { + Set addresses = getSolanaAddressesFromMessage(AMOUNT_OF_ADDRESSES, message); + addressVerifier.verifyAddressesAlreadyAdded(chatId, addresses); + Set addedAddresses = addresses.stream() + .map(address -> addSingleMonitoredAddress(chatId, address)) + .collect(Collectors.toSet()); + return String.format("Added following address(-es):\n%s", + formatAddressList(addedAddresses)); + } + + @Override + public String listAddresses(long chatId) { + List monitoredAddresses = addressRepository.findByChatId(chatId); + addressVerifier.verifyUserHasAddresses(monitoredAddresses); + Set solanaAddresses = monitoredAddresses.stream() + .map(MonitoredAddress::getAddress) + .collect(Collectors.toSet()); + return String.format("Your monitored address(-es):\n%s", + formatAddressList(solanaAddresses)); + } + + @Override + @Transactional + public String removeAddresses(long chatId, String message) { + Set addresses = getSolanaAddressesFromMessage(AMOUNT_OF_ADDRESSES, message); + addressVerifier.verifyUserHasCertainAddresses(chatId, addresses); + addresses.forEach(address -> { + addressRepository.deleteByAddressAndChatId(address, chatId); + }); + return String.format("Removed address(-es):\n%s", + formatAddressList(addresses)); + } + + private String formatAddressList(Set addresses) { + AtomicInteger count = new AtomicInteger(1); + return addresses.stream().map( + adr -> { + String url = buildSolscanAddressUrl(adr); + return String.format("%d. [%s](%s)", + count.getAndIncrement(), + shortenAddress(adr), + url); + }) + .collect(Collectors.joining("\n") + ); + } + + private String formatSingleMonitoredAddress(MonitoredAddress monitoredAddress) { + String addressUrl = buildSolscanAddressUrl(monitoredAddress.getAddress()); + String tokens = formatTokenList(monitoredAddress); + return String.format( + "Address link: [%s](%s)\nTokens:\n%s", + shortenAddress(monitoredAddress.getAddress()), + addressUrl, + tokens + ); + } + + private String formatTokenList(MonitoredAddress solanaAddress) { + AtomicInteger count = new AtomicInteger(1); + return solanaAddress.getTokens().stream() + .map(token -> { + String tokenUrl = SOLSCAN_TOKEN_URL + token.getTokenAddress() + "#holders"; + return String.format( + "%d. %s: [%s](%s)", + count.getAndIncrement(), + token.getTokenSymbol(), + shortenAddress(token.getTokenAddress()), + tokenUrl + ); + }) + .collect(Collectors.joining("\n")); + } + + private String addSingleMonitoredAddress(long chatId, String solanaAddress) { + Set tokensFromSolScan + = getFilteredTokensFromSolscan(solanaAddress); + + Set managedTokens = mapAndSaveTokensFromSolscan(tokensFromSolScan); + + MonitoredAddress address = new MonitoredAddress(); + address.setAddress(solanaAddress); + address.setChatId(chatId); + address.setTokens(managedTokens); + addressRepository.save(address); + return address.getAddress(); + } + + private String buildSolscanAddressUrl(String address) { + return SOLSCAN_ACCOUNT_URL + address + "#balanceChanges"; + } + + private String shortenAddress(String address) { + return address.length() > 8 + ? address.substring(0, 4) + "..." + address.substring(address.length() - 4) + : address; + } + + private Set mapAndSaveTokensFromSolscan( + Set tokensFromSolScan) { + return tokensFromSolScan.stream() + .map(tokenMapper::toModel) + .map(t -> tokenRepository.findByTokenAddress(t.getTokenAddress()) + .orElseGet(() -> tokenRepository.save(t))) + .collect(Collectors.toSet()); + } + + private Set getFilteredTokensFromSolscan( + String solanaAddress) { + return solScanService.getTokensByAddress(solanaAddress) + .stream() + .filter(tok -> tok.getTokenSymbol() != null) + .filter(tok -> tok.getTokenValue().compareTo(BigDecimal.TEN) > 0) + .sorted(Comparator.comparing(SingleTokenPortfolioResponseDto::getTokenValue) + .reversed()) + .limit(10) + .collect(Collectors.toSet()); + } + + private Set getSolanaAddressesFromMessage(int amountOfAddresses, String message) { + addressVerifier.verifyAddressIsProvided(message); + String normalizedMessage = message.replaceAll("\\s+", " ").trim(); + String[] addresses = normalizedMessage.substring( + normalizedMessage.indexOf(" ") + 1).split("\\s+"); + addressVerifier.verifyAmountOfAddresses(amountOfAddresses, addresses); + addressVerifier.verifyValidSolanaAddresses(addresses); + return Arrays.stream(addresses) + .filter(addr -> !addr.isEmpty()) + .collect(Collectors.toSet()); + } + + private String getHelpMessage() { + return """ + SolScan Bot Help + + This bot helps you monitor Solana addresses and track their token portfolios. + Here are the available commands: + + Basic Commands: + `/help` - Show this help message + `/list` - List all your monitored addresses + + Address Management: + `/add ` - Add up to 3 Solana addresses to monitor + `/remove ` - Remove addresses from monitoring + + Portfolio Tracking: + `/portfolio ` - Show token portfolio + of 10 most valuable tokens for specified addresses + + Usage Examples: + Add addresses: `/add D8wZ...3j4H G2eF...7k9L` + Remove address: `/remove D8wZ...3j4H` + Check portfolio: `/portfolio D8wZ...3j4H` + + Notes: + • With one request you can '/add', '/remove' or '/portfolio' up to 3 addresses at a time. + If you want to manage more addresses send more requests + • Addresses are displayed in shortened format (first 4 + last 4 chars) + """; + } +} diff --git a/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifier.java b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifier.java new file mode 100644 index 0000000..31b8b74 --- /dev/null +++ b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifier.java @@ -0,0 +1,25 @@ +package ivan.solscanbot.verifier; + +import ivan.solscanbot.dto.internal.MonitoredAddress; +import java.util.List; +import java.util.Set; + +public interface SolanaAddressVerifier { + void verifyAmountOfAddresses(int amount, String[] addresses); + + void verifyAddressIsProvided(String message); + + void verifyValidSolanaAddress(String address); + + void verifyUserHasCertainAddress(long chatId, String address); + + void verifyAddressAlreadyAdded(long chatId, String address); + + void verifyValidSolanaAddresses(String[] addresses); + + void verifyUserHasCertainAddresses(long chatId, Set addresses); + + void verifyAddressesAlreadyAdded(long chatId, Set addresses); + + void verifyUserHasAddresses(List addresses); +} diff --git a/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java new file mode 100644 index 0000000..a5394a9 --- /dev/null +++ b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java @@ -0,0 +1,83 @@ +package ivan.solscanbot.verifier; + +import ivan.solscanbot.dto.internal.MonitoredAddress; +import ivan.solscanbot.exception.AddressAlreadyExistsException; +import ivan.solscanbot.exception.AddressNotMonitoredException; +import ivan.solscanbot.exception.ExceedsAmountOfAddressesException; +import ivan.solscanbot.exception.InvalidAddressException; +import ivan.solscanbot.exception.UserNotHaveAnyMonitoredAddressesException; +import ivan.solscanbot.repository.MonitoredAddressRepository; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SolanaAddressVerifierImpl implements SolanaAddressVerifier { + private static final String SOLANA_ADDRESS_PATTERN = "[1-9A-HJ-NP-Za-km-z]{32,44}"; + + private final MonitoredAddressRepository addressRepository; + + @Override + public void verifyAmountOfAddresses(int amount, String[] addresses) { + if (addresses.length > amount) { + throw new ExceedsAmountOfAddressesException( + "You can only manage up to three addresses in a single request."); + } + } + + @Override + public void verifyAddressIsProvided(String message) { + if (message.trim().length() <= 1) { + throw new InvalidAddressException("No addresses provided."); + } + } + + @Override + public void verifyValidSolanaAddress(String address) { + if (address == null || !address.matches(SOLANA_ADDRESS_PATTERN)) { + throw new InvalidAddressException("Invalid Solana Address - " + address); + } + } + + @Override + public void verifyUserHasCertainAddress(long chatId, String address) { + if (!addressRepository.existsByAddressAndChatId(address, chatId)) { + throw new AddressNotMonitoredException( + "You need to add this address to monitored address first " + + "'/add address1 address2' ...'. Address: " + address); + } + } + + @Override + public void verifyAddressAlreadyAdded(long chatId, String address) { + if (addressRepository.existsByAddressAndChatId(address, chatId)) { + throw new AddressAlreadyExistsException( + "You already have this address in your list.\n\nAddress: " + address); + } + } + + @Override + public void verifyValidSolanaAddresses(String[] addresses) { + Arrays.stream(addresses).forEach(this::verifyValidSolanaAddress); + } + + @Override + public void verifyUserHasCertainAddresses(long chatId, Set addresses) { + addresses.forEach(address -> verifyUserHasCertainAddress(chatId, address)); + } + + @Override + public void verifyAddressesAlreadyAdded(long chatId, Set addresses) { + addresses.forEach(address -> verifyAddressAlreadyAdded(chatId, address)); + } + + @Override + public void verifyUserHasAddresses(List addresses) { + if (addresses.isEmpty()) { + throw new UserNotHaveAnyMonitoredAddressesException("You have no monitored addresses"); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7709139..accf6bb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,12 +3,11 @@ spring.application.name=solscanbot spring.thymeleaf.check-template-location=false spring.jpa.open-in-view=false -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console -spring.datasource.url=jdbc:h2:mem:scanbot - +# Optional Telegram and custom config telegram.bot.token=${TELEGRAM_BOT_TOKEN} telegram.bot.username=${TELEGRAM_BOT_USERNAME} sol.scan.url=${SOL_SCAN_URL} sol.scan.key=${SOL_SCAN_KEY} + +# ? Import the .env file for the variables above spring.config.import=optional:file:.env[.properties] diff --git a/src/main/resources/db/changelog/changes/create-database.yaml b/src/main/resources/db/changelog/changes/create-database.yaml new file mode 100644 index 0000000..d26b17a --- /dev/null +++ b/src/main/resources/db/changelog/changes/create-database.yaml @@ -0,0 +1,133 @@ +databaseChangeLog: + - changeSet: + id: 1 + author: ivan + changes: + - createTable: + tableName: tokens + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: token_symbol + type: VARCHAR(255) + - column: + name: token_address + type: VARCHAR(255) + constraints: + unique: true + nullable: false + - column: + name: is_deleted + type: BOOLEAN + defaultValueBoolean: false + + - changeSet: + id: 2 + author: ivan + changes: + - createTable: + tableName: monitored_addresses + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: address + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: chat_id + type: BIGINT + constraints: + nullable: false + - column: + name: is_deleted + type: BOOLEAN + defaultValueBoolean: false + + - changeSet: + id: 3 + author: ivan + changes: + - createTable: + tableName: balance_activities + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: token_address + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: token_name + type: VARCHAR(255) + - column: + name: token_symbol + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: value_in_usd + type: JAVA.MATH.BIGDECIMAL + constraints: + nullable: false + - column: + name: amount + type: JAVA.MATH.BIGDECIMAL + constraints: + nullable: false + - column: + name: time + type: TIMESTAMP + constraints: + nullable: false + - column: + name: address_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_balance_activity_address + references: monitored_addresses(id) + - column: + name: is_deleted + type: BOOLEAN + defaultValueBoolean: false + + - changeSet: + id: 4 + author: ivan + changes: + - createTable: + tableName: address_token + columns: + - column: + name: address_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_address_token_address + references: monitored_addresses(id) + - column: + name: token_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_address_token_token + references: tokens(id) diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..ef93391 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,3 @@ +databaseChangeLog: + - include: + file: db/changelog/changes/create-database.yaml diff --git a/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java b/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java index 0f6c821..06e5b76 100644 --- a/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java +++ b/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java @@ -1,13 +1,13 @@ package ivan.solscanbot; -import ivan.solscanbot.service.DeFiMonitorBot; +import ivan.solscanbot.bot.TelegramBot; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.telegram.telegrambots.meta.TelegramBotsApi; @SpringBootTest -@MockitoBean(types = {DeFiMonitorBot.class, TelegramBotsApi.class}) +@MockitoBean(types = {TelegramBot.class, TelegramBotsApi.class}) class SolScanBotApplicationTests { @Test void contextLoads() { diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index adeb4a3..8f57968 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,9 +1,3 @@ -spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password=password -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect - telegram.bot.token=bot_test telegram.bot.username=user_test sol.scan.url=url_test