diff --git a/application.properties b/application.properties index ce03717..470ccd5 100644 --- a/application.properties +++ b/application.properties @@ -7,3 +7,5 @@ spring.datasource.password=password spring.jpa.database-platform=org.hibernate.dialect.H2Dialect #- #spring.jpa.hibernate.ddl-auto=create +#- +de.ph87.kleinanzeigen.telegram.chat.whitelist=101138682,269710244 diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/FetchResult.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/FetchResult.java deleted file mode 100644 index 4d5bb43..0000000 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/FetchResult.java +++ /dev/null @@ -1,28 +0,0 @@ -package de.ph87.kleinanzeigen.kleinanzeigen; - -import lombok.Data; - -@Data -public class FetchResult { - - private int created = 0; - - private int updated = 0; - - private int error = 0; - - public void add(final MergeResult mergeResult) { - switch (mergeResult) { - case CREATED -> created++; - case UPDATED -> updated++; - case ERROR -> error++; - } - } - - public void merge(final FetchResult other) { - this.created += other.created; - this.updated += other.updated; - this.error += other.error; - } - -} diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenApi.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenApi.java index 8fa0acb..993e8a9 100644 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenApi.java +++ b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenApi.java @@ -1,14 +1,12 @@ package de.ph87.kleinanzeigen.kleinanzeigen; import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferCreate; -import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferDto; import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -25,52 +23,28 @@ public class KleinanzeigenApi { private static final String VERSCHENKEN_EPPELBORN_30KM = "https://www.kleinanzeigen.de/s-zu-verschenken/66571/seite:%d/c192l339r%d"; - private static final int FETCH_UNTIL_DUPLICATE_MAX_PAGES = 1; - private final OfferService offerService; - private final ApplicationEventPublisher publisher; - private final KleinanzeigenConfig config; @Scheduled(initialDelay = 0, fixedRate = 1, timeUnit = TimeUnit.MINUTES) public void fetch() { - fetchPagesUntilDuplicate(); - } - - private void fetchPagesUntilDuplicate() { - int page = 0; - final FetchResult totalFetchResult = new FetchResult(); - while (totalFetchResult.getUpdated() <= 0 && page < FETCH_UNTIL_DUPLICATE_MAX_PAGES) { - final FetchResult pageFetchResult = fetchPage(++page); - totalFetchResult.merge(pageFetchResult); - } - log.debug("FetchResult: {}", totalFetchResult); - } - - private FetchResult fetchPage(final int page) { - final FetchResult fetchResult = new FetchResult(); - final Document document; - final URI uri = URI.create(VERSCHENKEN_EPPELBORN_30KM.formatted(page, config.getRadiusKm())); + final URI uri = URI.create(VERSCHENKEN_EPPELBORN_30KM.formatted(1, config.getRadiusKm())); try { log.debug("Fetching page: {}", uri); - document = Jsoup.parse(uri.toURL(), 3000); + final Document document = Jsoup.parse(uri.toURL(), 3000); + document.select("li.ad-listitem:not(.is-topad) article.aditem").forEach(article -> tryParse(article, uri)); } catch (IOException e) { log.error("Failed to fetch Kleinanzeigen: {}", e.toString()); - return fetchResult; } - document.select("li.ad-listitem:not(.is-topad) article.aditem").forEach(article -> tryParse(article, uri, fetchResult)); - return fetchResult; } - private void tryParse(final Element article, final URI uri, final FetchResult fetchResult) { + private void tryParse(final Element article, final URI uri) { try { final OfferCreate create = new OfferCreate(article, uri); - final OfferDto dto = offerService.updateOrCreate(create, fetchResult); - publisher.publishEvent(dto); + offerService.updateOrCreate(create); } catch (NumberFormatException e) { log.error("Failed to parse Offer:\n{}\n", article.outerHtml(), e); - fetchResult.add(MergeResult.ERROR); } } diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenConfig.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenConfig.java index 1e9a298..5945f10 100644 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenConfig.java +++ b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/KleinanzeigenConfig.java @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; @Slf4j @Data @Component -@ConfigurationProperties(prefix = "de.ph87.kleinanzeigen") +@ConfigurationProperties(prefix = "de.ph87.kleinanzeigen.api") public class KleinanzeigenConfig { private int radiusKm = 15; diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/MergeResult.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/MergeResult.java deleted file mode 100644 index 57aac4c..0000000 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/MergeResult.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.ph87.kleinanzeigen.kleinanzeigen; - -public enum MergeResult { - CREATED, UPDATED, ERROR -} diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/Offer.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/Offer.java index 495fc49..917df45 100644 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/Offer.java +++ b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/Offer.java @@ -1,12 +1,17 @@ package de.ph87.kleinanzeigen.kleinanzeigen.offer; +import de.ph87.kleinanzeigen.telegram.chat.message.Message; import jakarta.annotation.Nullable; import jakarta.persistence.*; -import lombok.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.ToString; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Objects; @Entity @Getter @@ -29,17 +34,6 @@ public class Offer { @Column(nullable = false) private ZonedDateTime last = first; - @NonNull - @Column(nullable = false) - private ZonedDateTime expiry = first.plusDays(3); - - @Setter - @Column - private boolean hide = false; - - @Column - private boolean remember = false; - @NonNull @Column(nullable = false) private String articleId; @@ -76,35 +70,46 @@ public class Offer { @Nullable private String imageURL = null; - @Setter - @Column - @Nullable - private Integer telegramMessageId = null; + @OneToMany(mappedBy = "offer") + private List messages = new ArrayList<>(); public Offer(final @NonNull OfferCreate create) { this.articleId = create.getArticleId(); this.articleDate = create.getArticleDate(); - update(create); + change(create); } - public void update(final OfferCreate create) { - this.title = create.getTitle(); - this.zipcode = create.getZipcode(); - this.location = create.getLocation(); - this.distance = create.getDistance(); - this.description = create.getDescription(); - this.articleURL = create.getArticleURL(); - this.imageURL = create.getImageURL(); - } - - public void setRemember(final boolean newRemember) { - if (remember && !newRemember) { - final ZonedDateTime oneHour = ZonedDateTime.now().plusHours(1); - if (oneHour.isAfter(expiry)) { - expiry = oneHour; - } + public boolean change(final OfferCreate create) { + boolean changed = false; + if (!Objects.equals(this.title, create.getTitle())) { + this.title = create.getTitle(); + changed = true; } - remember = newRemember; + if (!Objects.equals(this.zipcode, create.getZipcode())) { + this.zipcode = create.getZipcode(); + changed = true; + } + if (!Objects.equals(this.location, create.getLocation())) { + this.location = create.getLocation(); + changed = true; + } + if (!Objects.equals(this.distance, create.getDistance())) { + this.distance = create.getDistance(); + changed = true; + } + if (!Objects.equals(this.description, create.getDescription())) { + this.description = create.getDescription(); + changed = true; + } + if (!Objects.equals(this.articleURL, create.getArticleURL())) { + this.articleURL = create.getArticleURL(); + changed = true; + } + if (!Objects.equals(this.imageURL, create.getImageURL())) { + this.imageURL = create.getImageURL(); + changed = true; + } + return changed; } } diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferDto.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferDto.java index 24eaf7d..f05acb9 100644 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferDto.java +++ b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferDto.java @@ -25,13 +25,6 @@ public class OfferDto { @NonNull private final ZonedDateTime last; - @NonNull - private final ZonedDateTime expiry; - - private final boolean hide; - - private final boolean remember; - @NonNull @ToString.Include private final String articleId; @@ -61,19 +54,11 @@ public class OfferDto { @Nullable private final String imageURL; - @Nullable - private final Integer telegramMessageId; - - private final boolean _existing_; - - public OfferDto(final @NonNull Offer offer, final boolean existing) { + public OfferDto(final @NonNull Offer offer) { id = offer.getId(); version = offer.getVersion(); first = offer.getFirst(); last = offer.getLast(); - expiry = offer.getExpiry(); - hide = offer.isHide(); - remember = offer.isRemember(); articleId = offer.getArticleId(); articleDate = offer.getArticleDate(); @@ -84,9 +69,6 @@ public class OfferDto { description = offer.getDescription(); articleURL = offer.getArticleURL(); imageURL = offer.getImageURL(); - telegramMessageId = offer.getTelegramMessageId(); - - _existing_ = existing; } public String combineLocation() { diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferRepository.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferRepository.java index 3fd5051..69c70d0 100644 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferRepository.java +++ b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferRepository.java @@ -2,16 +2,10 @@ package de.ph87.kleinanzeigen.kleinanzeigen.offer; import org.springframework.data.repository.ListCrudRepository; -import java.time.ZonedDateTime; -import java.util.List; import java.util.Optional; public interface OfferRepository extends ListCrudRepository { - List findAllByExpiryBefore(final ZonedDateTime deadline); - Optional findByArticleId(String articleId); - Optional findByTelegramMessageId(int telegramMessageId); - } diff --git a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferService.java b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferService.java index a09b088..fdff080 100644 --- a/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferService.java +++ b/src/main/java/de/ph87/kleinanzeigen/kleinanzeigen/offer/OfferService.java @@ -1,85 +1,43 @@ package de.ph87.kleinanzeigen.kleinanzeigen.offer; -import de.ph87.kleinanzeigen.kleinanzeigen.FetchResult; -import de.ph87.kleinanzeigen.kleinanzeigen.MergeResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.ZonedDateTime; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - @Slf4j @Service @Transactional -@EnableScheduling @RequiredArgsConstructor public class OfferService { - private final OfferRepository repository; + private final OfferRepository offerRepository; private final ApplicationEventPublisher publisher; - @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.DAYS) - public void cleanUp() { - final List list = repository.findAllByExpiryBefore(ZonedDateTime.now()); - repository.deleteAll(list); - list.stream().map(offer -> toDto(offer, false)).forEach(publisher::publishEvent); - } - - private OfferDto toDto(final Offer offer, final boolean existing) { - return new OfferDto(offer, existing); - } - - public OfferDto updateOrCreate(final OfferCreate create, final FetchResult fetchResult) { - return toDto(_updateOrCreate(create, fetchResult), true); - } - - private Offer _updateOrCreate(final OfferCreate create, final FetchResult fetchResult) { - return repository - .findByArticleId(create.getArticleId()) - .stream().peek( - existing -> { - existing.update(create); - fetchResult.add(MergeResult.UPDATED); - }) - .findFirst() - .orElseGet(() -> { - fetchResult.add(MergeResult.CREATED); - return repository.save(new Offer(create)); + public void updateOrCreate(final OfferCreate create) { + offerRepository.findByArticleId(create.getArticleId()).ifPresentOrElse( + existing -> { + if (existing.change(create)) { + final OfferDto dto = toDto(existing); + publisher.publishEvent(dto); } - ); + }, + () -> { + final Offer offer = offerRepository.save(new Offer(create)); + final OfferDto dto = toDto(offer); + publisher.publishEvent(dto); + } + ); } - @SuppressWarnings("UnusedReturnValue") - public Optional hideByTelegramMessageId(final int messageId, final boolean hide) { - return repository.findByTelegramMessageId(messageId).stream().peek(offer -> offer.setHide(hide)).findFirst().map(offer -> toDto(offer, true)); + public OfferDto toDto(final Offer offer) { + return new OfferDto(offer); } - public Optional rememberByTelegramMessageId(final int messageId, final boolean remember) { - return repository.findByTelegramMessageId(messageId).stream().peek(offer -> offer.setRemember(remember)).findFirst().map(offer -> toDto(offer, true)); - } - - public Optional findByTelegramMessageId(final int messageId) { - return repository.findByTelegramMessageId(messageId).map(offer -> toDto(offer, true)); - } - - public void setTelegramMessageId(final OfferDto dto, final Integer telegramMessageId) { - findByDto(dto).ifPresent(offer -> offer.setTelegramMessageId(telegramMessageId)); - } - - public void setHide(final OfferDto dto, final boolean hide) { - findByDto(dto).ifPresent(offer -> offer.setHide(hide)); - } - - private Optional findByDto(final OfferDto dto) { - return repository.findById(dto.getId()); + public Offer getByDto(final OfferDto offerDto) { + return offerRepository.findById(offerDto.getId()).orElseThrow(); } } diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/AccessDenied.java b/src/main/java/de/ph87/kleinanzeigen/telegram/AccessDenied.java new file mode 100644 index 0000000..42ffd22 --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/AccessDenied.java @@ -0,0 +1,5 @@ +package de.ph87.kleinanzeigen.telegram; + +public class AccessDenied extends Exception { + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/TelegramService.java b/src/main/java/de/ph87/kleinanzeigen/telegram/TelegramService.java index 02dbfc2..9bb7339 100644 --- a/src/main/java/de/ph87/kleinanzeigen/telegram/TelegramService.java +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/TelegramService.java @@ -3,15 +3,19 @@ package de.ph87.kleinanzeigen.telegram; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferDto; -import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferService; +import de.ph87.kleinanzeigen.telegram.chat.ChatDto.ChatDto; +import de.ph87.kleinanzeigen.telegram.chat.ChatService; +import de.ph87.kleinanzeigen.telegram.chat.message.MessageDeleted; +import de.ph87.kleinanzeigen.telegram.chat.message.MessageDto; +import de.ph87.kleinanzeigen.telegram.chat.message.MessageService; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; import org.telegram.telegrambots.meta.api.methods.send.SendPhoto; -import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessages; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageCaption; import org.telegram.telegrambots.meta.api.objects.*; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; @@ -26,26 +30,24 @@ import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.Objects; @Slf4j @Service @RequiredArgsConstructor public class TelegramService { - private static final long CHAT_ID = 101138682L; - private static final String ICON_CHECK = "✅"; private static final String ICON_REMOVE = "❌"; + private final ChatService chatService; + private byte[] NO_IMAGE = null; private final ObjectMapper objectMapper; - private final OfferService offerService; + private final MessageService messageService; private TelegramBot bot = null; @@ -63,19 +65,22 @@ public class TelegramService { } } - @EventListener(OfferDto.class) - public void onOfferChanged(final OfferDto offer) { - if (offer.is_existing_()) { - if (offer.getTelegramMessageId() == null) { - send(offer); - } else { - updateMessage(CHAT_ID, offer.getTelegramMessageId()); - } - } else { - remove(List.of(offer)); + @TransactionalEventListener(OfferDto.class) + public void onOffer(final OfferDto offer) { + final List existing = messageService.findAllDtoByOfferDto(offer); + for (ChatDto chat : chatService.findAllEnabled()) { + existing.stream() + .filter(m -> m.getChat().getId() == chat.getId()) + .findFirst() + .ifPresentOrElse(this::update, () -> send(offer, chat)); } } + @TransactionalEventListener(MessageDeleted.class) + public void onMessageDeleted(final MessageDeleted messageDeleted) { + remove(messageDeleted.getChatId(), messageDeleted.getMessageId()); + } + @PreDestroy public void stop() { log.info("Stopping Telegram bot..."); @@ -90,102 +95,113 @@ public class TelegramService { } private void onUpdateReceived(final Update update) { - if (update.hasMessage() && update.getMessage().hasText()) { - log.info("#{} \"{}\": {}", update.getMessage().getChat().getId(), update.getMessage().getChat().getUserName(), update.getMessage().getText()); - } else if (update.hasCallbackQuery()) { - handleCallbackQuery(update.getCallbackQuery()); + try { + if (update.hasMessage() && update.getMessage().hasText()) { + handleMessage(update.getMessage()); + } else if (update.hasCallbackQuery()) { + handleCallback(update.getCallbackQuery()); + } + } catch (AccessDenied e) { + log.warn("Access denied: {}", update); } } - private void handleCallbackQuery(final CallbackQuery query) { - final MaybeInaccessibleMessage message = query.getMessage(); - if (message.getChatId() != CHAT_ID) { - return; - } + private void handleMessage(final Message message) throws AccessDenied { + chatService.setEnabled(message.getChatId(), !message.getText().equals("/stop")); + } + + private void handleCallback(final CallbackQuery callback) throws AccessDenied { + final MaybeInaccessibleMessage tlgMessage = callback.getMessage(); + chatService.setEnabled(tlgMessage.getChatId(), true); try { - final InlineDto dto = objectMapper.readValue(query.getData(), InlineDto.class); + final InlineDto dto = objectMapper.readValue(callback.getData(), InlineDto.class); switch (dto.getCommand()) { - case HIDE -> hide(message); - case REMEMBER -> remember(message, true); - case UNREMEMBER -> remember(message, false); - default -> updateMessage(message.getChatId(), message.getMessageId()); + case HIDE -> hide(tlgMessage); + case REMEMBER -> remember(tlgMessage, true); + case UNREMEMBER -> remember(tlgMessage, false); + default -> update(tlgMessage); } } catch (JsonProcessingException e) { log.error("Failed to read InlineDto.", e); } } - private void hide(final MaybeInaccessibleMessage message) { - offerService.hideByTelegramMessageId(message.getMessageId(), true); - _remove(message.getMessageId()); + private void hide(final MaybeInaccessibleMessage tlgMessage) { + messageService.setHide(tlgMessage, true).ifPresentOrElse(this::update, () -> remove(tlgMessage)); } - private void remember(final MaybeInaccessibleMessage message, final boolean remember) { - offerService.rememberByTelegramMessageId(message.getMessageId(), remember).ifPresentOrElse( - offer -> updateMessage(offer, message.getChatId(), message.getMessageId()), - () -> _remove(message.getMessageId()) - ); + private void remember(final MaybeInaccessibleMessage tlgMessage, final boolean remember) { + messageService.setRemember(tlgMessage, remember).ifPresentOrElse(this::update, () -> remove(tlgMessage)); } - private void updateMessage(final long chatId, final int messageId) { - offerService.findByTelegramMessageId(messageId).ifPresentOrElse( - offer -> updateMessage(offer, chatId, messageId), - () -> _remove(messageId) - ); + private void update(final MaybeInaccessibleMessage tlgMessage) { + messageService.findDtoByTelegramMessage(tlgMessage).ifPresentOrElse(this::update, () -> remove(tlgMessage)); } - private void updateMessage(final OfferDto offer, final long chatId, final int messageId) { - if (offer.isHide()) { + private void send(final OfferDto offerDto, final ChatDto chatDto) { + chatService.findAllEnabled().forEach(chat -> { + try { + final InputFile inputFile = offerDto.getImageURL() == null ? new InputFile(new ByteArrayInputStream(NO_IMAGE), "[Kein Bild]") : new InputFile(offerDto.getImageURL()); + final SendPhoto send = new SendPhoto(chat.getId() + "", inputFile); + send.setCaption(createText(offerDto)); + send.setReplyMarkup(createKeyboard(false)); + + log.info("Sending Offer: {}", offerDto); + final Message message = bot.execute(send); + + messageService.create(offerDto, chatDto, message); + } catch (TelegramApiException | JsonProcessingException e) { + log.error("Failed to send Message to #{}.", chat.getId(), e); + } + }); + } + + private void update(final MessageDto messageDto) { + if (messageDto.isHide() && messageDto.getTelegramMessageId() != null) { + remove(messageDto.getChat().getId(), messageDto.getTelegramMessageId()); + messageService.clearTelegramMessageId(messageDto); return; } + try { - final EditMessageCaption edit = new EditMessageCaption(chatId + "", messageId, null, createText(offer), createKeyboard(offer), null, null); + final EditMessageCaption edit = new EditMessageCaption( + messageDto.getChat().getId() + "", + messageDto.getTelegramMessageId(), + null, + createText(messageDto.getOffer()), + createKeyboard(messageDto.isRemember()), + null, + null + ); edit.setParseMode("Markdown"); - log.info("Editing Offer: {}", offer); + log.info("Editing Offer: {}", messageDto); bot.execute(edit); } catch (TelegramApiException | JsonProcessingException e) { if (e.toString().endsWith("Bad Request: message to edit not found")) { - log.info("Message has been deleted by User. Marking has hidden: {}", offer); - offerService.setHide(offer, true); + log.info("Message has been deleted by User. Marking has hidden: {}", messageDto); + messageService.setHide(messageDto, true); } else if (e.toString().endsWith("Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message")) { - log.debug("Ignoring complaint from telegram-bot-api about unmodified message: {}", offer); + log.debug("Ignoring complaint from telegram-bot-api about unmodified message: {}", messageDto); } else { - log.error("Failed to edit Message to #{}.", chatId, e); + log.error("Failed to edit Message chat={} message={}", messageDto.getChat().getId(), messageDto.getTelegramMessageId(), e); } } } - private void send(final OfferDto offer) { - try { - final InputFile inputFile = offer.getImageURL() == null ? new InputFile(new ByteArrayInputStream(NO_IMAGE), "[Kein Bild]") : new InputFile(offer.getImageURL()); - final SendPhoto send = new SendPhoto(CHAT_ID + "", inputFile); - send.setCaption(createText(offer)); - send.setReplyMarkup(createKeyboard(offer)); - - log.info("Sending Offer: {}", offer); - final Message message = bot.execute(send); - - offerService.setTelegramMessageId(offer, message.getMessageId()); - } catch (TelegramApiException | JsonProcessingException e) { - log.error("Failed to send Message to #{}.", CHAT_ID, e); - } - } - private String createText(final OfferDto offer) { - return "%s\n%s\n%s\n%s\nv%d".formatted( + return "%s\n%s\n%s\n%s\n".formatted( offer.getTitle().replaceAll("\\[", "(").replaceAll("]", ")"), offer.combineLocation(), offer.getDescription(), - offer.getArticleURL(), - offer.getVersion() + offer.getArticleURL() ); } - private InlineKeyboardMarkup createKeyboard(final OfferDto offer) throws JsonProcessingException { + private InlineKeyboardMarkup createKeyboard(final boolean remember) throws JsonProcessingException { final InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); final ArrayList> keyboard = new ArrayList<>(); final ArrayList row = new ArrayList<>(); - if (offer.isRemember()) { + if (remember) { addButton(row, ICON_CHECK + ICON_CHECK + ICON_CHECK + " Gemerkt " + ICON_CHECK + ICON_CHECK + ICON_CHECK, InlineCommand.UNREMEMBER); } else { addButton(row, ICON_REMOVE + " Entfernen", InlineCommand.HIDE); @@ -201,16 +217,16 @@ public class TelegramService { row.add(new InlineKeyboardButton(caption, null, data, null, null, null, null, null, null)); } - private void remove(final List offers) { - _remove(offers.stream().map(OfferDto::getTelegramMessageId).filter(Objects::nonNull).toArray(Integer[]::new)); + private void remove(final MaybeInaccessibleMessage tlgMessage) { + remove(tlgMessage.getChatId(), tlgMessage.getMessageId()); } - private void _remove(final Integer... messageIds) { + private void remove(final long chatId, final int messageId) { try { - log.info("Removing messages: {}", Arrays.toString(messageIds)); - bot.execute(new DeleteMessages(CHAT_ID + "", Arrays.stream(messageIds).toList())); + log.error("Removing Message chat={} message={}", chatId, messageId); + bot.execute(new DeleteMessage(chatId + "", messageId)); } catch (TelegramApiException e) { - log.error("Failed to remove Message #{}.", CHAT_ID, e); + log.error("Failed to remove Message chat={} message={}", chatId, messageId); } } diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/Chat.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/Chat.java new file mode 100644 index 0000000..576cbae --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/Chat.java @@ -0,0 +1,27 @@ +package de.ph87.kleinanzeigen.telegram.chat; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Getter +@ToString +@NoArgsConstructor +public class Chat { + + @Id + private long id; + + @Setter + private boolean enabled; + + public Chat(final long id, final boolean enabled) { + this.id = id; + this.enabled = enabled; + } + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatConfig.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatConfig.java new file mode 100644 index 0000000..f5f37ed --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatConfig.java @@ -0,0 +1,23 @@ +package de.ph87.kleinanzeigen.telegram.chat; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Data +@Component +@ConfigurationProperties(prefix = "de.ph87.kleinanzeigen.telegram.chat") +public class ChatConfig { + + private List whitelist = new ArrayList<>(); + + public boolean isOnWhitelist(final long id) { + return whitelist.contains(id); + } + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatDto/ChatDto.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatDto/ChatDto.java new file mode 100644 index 0000000..01884e9 --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatDto/ChatDto.java @@ -0,0 +1,18 @@ +package de.ph87.kleinanzeigen.telegram.chat.ChatDto; + +import de.ph87.kleinanzeigen.telegram.chat.Chat; +import lombok.Data; + +@Data +public class ChatDto { + + private final long id; + + private final boolean enabled; + + public ChatDto(final Chat chat) { + this.id = chat.getId(); + this.enabled = chat.isEnabled(); + } + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatRepository.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatRepository.java new file mode 100644 index 0000000..bd1eeb7 --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatRepository.java @@ -0,0 +1,11 @@ +package de.ph87.kleinanzeigen.telegram.chat; + +import org.springframework.data.repository.ListCrudRepository; + +import java.util.List; + +public interface ChatRepository extends ListCrudRepository { + + List findAllByEnabledTrue(); + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatService.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatService.java new file mode 100644 index 0000000..20c5898 --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/ChatService.java @@ -0,0 +1,55 @@ +package de.ph87.kleinanzeigen.telegram.chat; + +import de.ph87.kleinanzeigen.telegram.AccessDenied; +import de.ph87.kleinanzeigen.telegram.chat.ChatDto.ChatDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class ChatService { + + private final ChatRepository chatRepository; + + private final ChatConfig config; + + public List findAllEnabled() { + return chatRepository.findAllByEnabledTrue().stream().filter(chat -> config.isOnWhitelist(chat.getId())).map(this::toDto).toList(); + } + + public void setEnabled(final long id, final boolean enabled) throws AccessDenied { + if (!config.isOnWhitelist(id)) { + throw new AccessDenied(); + } + chatRepository + .findById(id) + .stream() + .peek(chat -> { + if (chat.isEnabled() != enabled) { + chat.setEnabled(enabled); + log.info("Chat {}: {}", enabled ? "ENABLED" : "DISABLED", chat); + } + }) + .findFirst() + .orElseGet(() -> { + final Chat chat = chatRepository.save(new Chat(id, enabled)); + log.info("Chat created: {}", chat); + return chat; + }); + } + + public Chat getByDto(final ChatDto chatDto) { + return chatRepository.findById(chatDto.getId()).orElseThrow(); + } + + public ChatDto toDto(final Chat chat) { + return new ChatDto(chat); + } + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/Message.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/Message.java new file mode 100644 index 0000000..81d1447 --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/Message.java @@ -0,0 +1,61 @@ +package de.ph87.kleinanzeigen.telegram.chat.message; + +import de.ph87.kleinanzeigen.kleinanzeigen.offer.Offer; +import de.ph87.kleinanzeigen.telegram.chat.Chat; +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import lombok.*; + +import java.time.ZonedDateTime; + +@Entity +@Getter +@ToString +@NoArgsConstructor +public class Message { + + @Id + @GeneratedValue + private long id; + + @NonNull + @ManyToOne(optional = false) + private Chat chat; + + @NonNull + @ManyToOne(optional = false) + private Offer offer; + + @Setter + @Column + @Nullable + private Integer telegramMessageId; + + @NonNull + @Column(nullable = false) + private ZonedDateTime expiry = ZonedDateTime.now().plusDays(3); + + @Setter + @Column + private boolean hide = false; + + @Column + private boolean remember = false; + + public Message(@NonNull final Offer offer, @NonNull final Chat chat, final org.telegram.telegrambots.meta.api.objects.Message message) { + this.chat = chat; + this.offer = offer; + this.telegramMessageId = message.getMessageId(); + } + + public void setRemember(final boolean newRemember) { + if (remember && !newRemember) { + final ZonedDateTime oneHour = ZonedDateTime.now().plusHours(1); + if (oneHour.isAfter(expiry)) { + expiry = oneHour; + } + } + remember = newRemember; + } + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageDeleted.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageDeleted.java new file mode 100644 index 0000000..707acbc --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageDeleted.java @@ -0,0 +1,17 @@ +package de.ph87.kleinanzeigen.telegram.chat.message; + +import lombok.Data; + +@Data +public class MessageDeleted { + + private final long chatId; + + private final int messageId; + + public MessageDeleted(final long chatId, final int messageId) { + this.chatId = chatId; + this.messageId = messageId; + } + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageDto.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageDto.java new file mode 100644 index 0000000..066a29a --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageDto.java @@ -0,0 +1,41 @@ +package de.ph87.kleinanzeigen.telegram.chat.message; + +import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferDto; +import de.ph87.kleinanzeigen.telegram.chat.ChatDto.ChatDto; +import jakarta.annotation.Nullable; +import lombok.Data; +import lombok.NonNull; + +import java.time.ZonedDateTime; + +@Data +public class MessageDto { + + private final long id; + + @NonNull + private final ChatDto chat; + + @NonNull + private final OfferDto offer; + + @Nullable + private final Integer telegramMessageId; + + private final ZonedDateTime expiry; + + private final boolean hide; + + private final boolean remember; + + public MessageDto(final Message message, @NonNull final ChatDto chatDto, final @NonNull OfferDto offerDto) { + id = message.getId(); + chat = chatDto; + offer = offerDto; + telegramMessageId = message.getTelegramMessageId(); + expiry = message.getExpiry(); + hide = message.isHide(); + remember = message.isRemember(); + } + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageRepository.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageRepository.java new file mode 100644 index 0000000..8c4e9c2 --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageRepository.java @@ -0,0 +1,17 @@ +package de.ph87.kleinanzeigen.telegram.chat.message; + +import org.springframework.data.repository.ListCrudRepository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface MessageRepository extends ListCrudRepository { + + Optional findByChat_IdAndTelegramMessageId(long chatId, int messageId); + + List findAllByOffer_id(long id); + + List findAllByExpiryBefore(ZonedDateTime deadline); + +} diff --git a/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageService.java b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageService.java new file mode 100644 index 0000000..3d63a07 --- /dev/null +++ b/src/main/java/de/ph87/kleinanzeigen/telegram/chat/message/MessageService.java @@ -0,0 +1,100 @@ +package de.ph87.kleinanzeigen.telegram.chat.message; + +import de.ph87.kleinanzeigen.kleinanzeigen.offer.Offer; +import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferDto; +import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferRepository; +import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferService; +import de.ph87.kleinanzeigen.telegram.chat.Chat; +import de.ph87.kleinanzeigen.telegram.chat.ChatDto.ChatDto; +import de.ph87.kleinanzeigen.telegram.chat.ChatService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.telegram.telegrambots.meta.api.objects.MaybeInaccessibleMessage; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@Transactional +@EnableScheduling +@RequiredArgsConstructor +public class MessageService { + + private final MessageRepository messageRepository; + + private final OfferRepository offerRepository; + + private final OfferService offerService; + + private final ChatService chatService; + + private final ApplicationEventPublisher publisher; + + @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.HOURS) + public void cleanUp() { + for (final Message message : messageRepository.findAllByExpiryBefore(ZonedDateTime.now())) { + message.getOffer().getMessages().remove(message); + messageRepository.delete(message); + if (message.getOffer().getMessages().isEmpty()) { + offerRepository.delete(message.getOffer()); + } + if (message.getTelegramMessageId() != null) { + publisher.publishEvent(new MessageDeleted(message.getChat().getId(), message.getTelegramMessageId())); + } + } + } + + public void create(final OfferDto offerDto, final ChatDto chatDto, final org.telegram.telegrambots.meta.api.objects.Message message) { + final Offer offer = offerService.getByDto(offerDto); + final Chat chat = chatService.getByDto(chatDto); + messageRepository.save(new Message(offer, chat, message)); + } + + @SuppressWarnings("UnusedReturnValue") + public Optional setHide(final MaybeInaccessibleMessage tlgMessage, final boolean hide) { + return findByTelegramMessage(tlgMessage).stream().peek(offer -> offer.setHide(hide)).findFirst().map(this::toDto); + } + + public Optional setRemember(final MaybeInaccessibleMessage tlgMessage, final boolean remember) { + return findByTelegramMessage(tlgMessage).stream().peek(offer -> offer.setRemember(remember)).findFirst().map(this::toDto); + } + + public Optional findDtoByTelegramMessage(final MaybeInaccessibleMessage tlgMessage) { + return findByTelegramMessage(tlgMessage).map(this::toDto); + } + + public void clearTelegramMessageId(final MessageDto dto) { + findByDto(dto).ifPresent(message -> message.setTelegramMessageId(null)); + } + + public void setHide(final MessageDto dto, final boolean hide) { + findByDto(dto).ifPresent(offer -> offer.setHide(hide)); + } + + private Optional findByTelegramMessage(final MaybeInaccessibleMessage tlgMessage) { + return messageRepository.findByChat_IdAndTelegramMessageId(tlgMessage.getChatId(), tlgMessage.getMessageId()); + } + + private Optional findByDto(final MessageDto dto) { + return messageRepository.findById(dto.getId()); + } + + public List findAllDtoByOfferDto(final OfferDto offer) { + return messageRepository.findAllByOffer_id(offer.getId()).stream().map(this::toDto).toList(); + } + + private MessageDto toDto(final Message message) { + final ChatDto chatDto = chatService.toDto(message.getChat()); + final OfferDto offerDto = offerService.toDto(message.getOffer()); + return new MessageDto(message, chatDto, offerDto); + } + +}