diff --git a/src/main/java/de/ph87/kleinanzeigen/api/Bot.java b/src/main/java/de/ph87/kleinanzeigen/api/Bot.java index 1d104af..4687530 100644 --- a/src/main/java/de/ph87/kleinanzeigen/api/Bot.java +++ b/src/main/java/de/ph87/kleinanzeigen/api/Bot.java @@ -4,45 +4,58 @@ import com.fasterxml.jackson.core.JsonProcessingException; import lombok.extern.slf4j.Slf4j; import org.telegram.telegrambots.bots.TelegramLongPollingBot; import org.telegram.telegrambots.meta.TelegramBotsApi; -import org.telegram.telegrambots.meta.api.methods.send.SendMessage; import org.telegram.telegrambots.meta.api.methods.send.SendPhoto; import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessages; import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageCaption; -import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageText; import org.telegram.telegrambots.meta.api.objects.*; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import org.telegram.telegrambots.meta.exceptions.TelegramApiException; import org.telegram.telegrambots.updatesreceivers.DefaultBotSession; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Function; import static de.ph87.kleinanzeigen.api.JSON.objectMapper; @Slf4j public class Bot extends TelegramLongPollingBot { + private static final long CHAT_ID = 101138682L; + private static final String ICON_CHECK = "✅"; private static final String ICON_REMOVE = "❌"; - private static final long CHAT_ID = 101138682L; + private final byte[] NO_IMAGE; private final DefaultBotSession session; private final Consumer ignore; + private final Function> find; + private final BiFunction> remember; - public Bot(final Consumer ignore, final BiFunction> remember) throws IOException, TelegramApiException { + public Bot(final Consumer ignore, final Function> find, final BiFunction> remember) throws IOException, TelegramApiException { super(readToken()); + + final BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + ImageIO.write(img, "PNG", stream); + NO_IMAGE = stream.toByteArray(); + this.ignore = ignore; + this.find = find; this.remember = remember; log.info("Starting telegram bot..."); final TelegramBotsApi api = new TelegramBotsApi(DefaultBotSession.class); @@ -73,8 +86,9 @@ public class Bot extends TelegramLongPollingBot { final InlineDto dto = objectMapper.readValue(query.getData(), InlineDto.class); switch (dto.getCommand()) { case IGNORE -> ignore(message); - case REMEMBER -> remember(message, dto); - case UNREMEMBER -> unremember(message, dto); + case REMEMBER -> remember(message); + case UNREMEMBER -> unremember(message); + default -> updateMessage(message); } } catch (JsonProcessingException e) { log.error("Failed to read InlineDto.", e); @@ -86,30 +100,34 @@ public class Bot extends TelegramLongPollingBot { remove(message); } - private void remember(final MaybeInaccessibleMessage message, final InlineDto dto) { - remember.apply(message, true).ifPresent(offer -> editMessage(message, offer, dto)); + private void remember(final MaybeInaccessibleMessage message) { + remember.apply(message, true).ifPresentOrElse( + offer -> updateMessage(message, offer), + () -> remove(message) + ); } - private void unremember(final MaybeInaccessibleMessage message, final InlineDto dto) { - remember.apply(message, false).ifPresent(offer -> editMessage(message, offer, dto)); + private void unremember(final MaybeInaccessibleMessage message) { + remember.apply(message, false).ifPresentOrElse( + offer -> updateMessage(message, offer), + () -> remove(message) + ); } - private void editMessage(final MaybeInaccessibleMessage message, final Offer offer, final InlineDto dto) { + private void updateMessage(final MaybeInaccessibleMessage message) { + find.apply(message).ifPresentOrElse( + offer -> updateMessage(message, offer), + () -> remove(message) + ); + } + + private void updateMessage(final MaybeInaccessibleMessage message, final Offer offer) { try { - switch (dto.getType()) { - case TEXT -> { - final EditMessageText edit = new EditMessageText(message.getChatId() + "", message.getMessageId(), null, getText(offer), null, null, getKeyboard(offer), null, null); - edit.setParseMode("Markdown"); - execute(edit); - } - case PHOTO -> { - final EditMessageCaption edit = new EditMessageCaption(message.getChatId() + "", message.getMessageId(), null, getText(offer), getKeyboard(offer), null, null); - edit.setParseMode("Markdown"); - execute(edit); - } - } + final EditMessageCaption edit = new EditMessageCaption(message.getChatId() + "", message.getMessageId(), null, createText(offer), createKeyboard(offer), null, null); + edit.setParseMode("Markdown"); + execute(edit); } catch (TelegramApiException | JsonProcessingException e) { - log.error("Failed to edit Message to #{}.", CHAT_ID, e); + log.error("Failed to edit Message to #{}.", message.getChatId(), e); } } @@ -121,19 +139,12 @@ public class Bot extends TelegramLongPollingBot { public void send(final Offer offer) { try { - final Message message; - if (offer.getImage().isEmpty()) { - final SendMessage send = new SendMessage(CHAT_ID + "", getText(offer)); - send.setParseMode("Markdown"); - send.setReplyMarkup(getKeyboard(offer)); - message = execute(send); - } else { - final SendPhoto send = new SendPhoto(CHAT_ID + "", new InputFile(offer.getImage())); - send.setCaption(getText(offer)); - send.setParseMode("Markdown"); - send.setReplyMarkup(getKeyboard(offer)); - message = execute(send); - } + final InputFile inputFile = offer.getImage().isEmpty() ? new InputFile(new ByteArrayInputStream(NO_IMAGE), "[Kein Bild]") : new InputFile(offer.getImage()); + final SendPhoto send = new SendPhoto(CHAT_ID + "", inputFile); + send.setCaption(createText(offer)); + send.setParseMode("Markdown"); + send.setReplyMarkup(createKeyboard(offer)); + final Message message = execute(send); offer.setTelegramMessageId(message.getMessageId()); } catch (TelegramApiException | JsonProcessingException e) { @@ -141,39 +152,32 @@ public class Bot extends TelegramLongPollingBot { } } - private String getText(final Offer offer) { - return "[%s](%s)\nVor %s\n%s\n%s".formatted( - escape(offer.getTitle()), + private String createText(final Offer offer) { + return "[%s](%s)\n%s\n%s".formatted( + offer.getTitle().replaceAll("\\[", "(").replaceAll("]", ")"), offer.getHref(), - offer.ageString(), offer.calculateLocationString(), offer.getDescription() ); } - private String escape(final String text) { - // TODO make smarter (real escaping) - return text.replaceAll("\\[", "(").replaceAll("]", ")"); - } - - private InlineKeyboardMarkup getKeyboard(final Offer offer) throws JsonProcessingException { + private InlineKeyboardMarkup createKeyboard(final Offer offer) throws JsonProcessingException { final InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); final ArrayList> keyboard = new ArrayList<>(); final ArrayList row = new ArrayList<>(); if (offer.isRemember()) { - addButton(row, ICON_CHECK + ICON_CHECK + ICON_CHECK + " Gemerkt " + ICON_CHECK + ICON_CHECK + ICON_CHECK, InlineCommand.UNREMEMBER, offer); + addButton(row, ICON_CHECK + ICON_CHECK + ICON_CHECK + " Gemerkt " + ICON_CHECK + ICON_CHECK + ICON_CHECK, InlineCommand.UNREMEMBER); } else { - addButton(row, ICON_REMOVE + " Ignorieren", InlineCommand.IGNORE, offer); - addButton(row, ICON_CHECK + " Merken", InlineCommand.REMEMBER, offer); + addButton(row, ICON_REMOVE + " Entfernen", InlineCommand.IGNORE); + addButton(row, ICON_CHECK + " Merken", InlineCommand.REMEMBER); } keyboard.add(row); markup.setKeyboard(keyboard); return markup; } - private void addButton(final ArrayList row, final String caption, final InlineCommand command, final Offer offer) throws JsonProcessingException { - final InlineDto dto = new InlineDto(command, InlineType.of(offer)); - final String data = objectMapper.writeValueAsString(dto); + private void addButton(final ArrayList row, final String caption, final InlineCommand command) throws JsonProcessingException { + final String data = objectMapper.writeValueAsString(new InlineDto(command)); row.add(new InlineKeyboardButton(caption, null, data, null, null, null, null, null, null)); } diff --git a/src/main/java/de/ph87/kleinanzeigen/api/InlineDto.java b/src/main/java/de/ph87/kleinanzeigen/api/InlineDto.java index 8950654..4c219f5 100644 --- a/src/main/java/de/ph87/kleinanzeigen/api/InlineDto.java +++ b/src/main/java/de/ph87/kleinanzeigen/api/InlineDto.java @@ -1,19 +1,18 @@ package de.ph87.kleinanzeigen.api; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class InlineDto { private InlineCommand command; - private InlineType type; - - public InlineDto(final InlineCommand command, final InlineType type) { + public InlineDto(final InlineCommand command) { this.command = command; - this.type = type; } } diff --git a/src/main/java/de/ph87/kleinanzeigen/api/InlineType.java b/src/main/java/de/ph87/kleinanzeigen/api/InlineType.java deleted file mode 100644 index 674f184..0000000 --- a/src/main/java/de/ph87/kleinanzeigen/api/InlineType.java +++ /dev/null @@ -1,13 +0,0 @@ -package de.ph87.kleinanzeigen.api; - -public enum InlineType { - TEXT, PHOTO; - - public static InlineType of(final Offer offer) { - if (offer.getImage() == null) { - return TEXT; - } - return PHOTO; - } - -} diff --git a/src/main/java/de/ph87/kleinanzeigen/api/Kleinanzeigen.java b/src/main/java/de/ph87/kleinanzeigen/api/Kleinanzeigen.java index 4dbb028..9dee4d1 100644 --- a/src/main/java/de/ph87/kleinanzeigen/api/Kleinanzeigen.java +++ b/src/main/java/de/ph87/kleinanzeigen/api/Kleinanzeigen.java @@ -70,9 +70,9 @@ public class Kleinanzeigen { offers.sort(Comparator.comparing(Offer::getDate)); final List deleted = new ArrayList<>(); - final List notToRemember = new ArrayList<>(offers.stream().filter(offer -> !offer.isRemember()).toList()); - while (!notToRemember.isEmpty() && notToRemember.size() > Kleinanzeigen.KEEP_LAST_OFFERS_COUNT) { - final Offer offer = notToRemember.removeFirst(); + final List removable = new ArrayList<>(offers.stream().filter(offer -> !offer.isRemember() && offer.getRememberUntil() == null).toList()); + while (!removable.isEmpty() && removable.size() > Kleinanzeigen.KEEP_LAST_OFFERS_COUNT) { + final Offer offer = removable.removeFirst(); offers.remove(offer); offer.markDeleted(); deleted.add(offer); @@ -105,7 +105,6 @@ public class Kleinanzeigen { final String description = article.select(".aditem-main--middle--description").text(); final ZonedDateTime date = parseDate(article.select(".aditem-main--top--right").text()); final String articleURL = VERSCHENKEN_EPPELBORN_30KM.resolve(article.select(".aditem-image a").attr("href")).toString(); - final String imageURL = article.select(".aditem-image img").attr("src"); final String zipcode; final String location; final Integer distance; @@ -120,23 +119,34 @@ public class Kleinanzeigen { location = locationMatcher.group("location"); distance = Integer.parseInt(locationMatcher.group("distance")); } + + final String imageURL = getImageURL(articleURL); + return new Offer(id, date, title, zipcode, location, distance, description, articleURL, imageURL); - } catch (NumberFormatException e) { + } catch (NumberFormatException | IOException e) { throw new OfferParseException(article, e); } } + private String getImageURL(final String articleURL) throws IOException { + final String imageURL; + final Document document = Jsoup.parse(URI.create(articleURL).toURL(), 3000); + final Element image = document.select(".galleryimage-element img").first(); + if (image == null) { + imageURL = ""; + } else { + imageURL = image.attr("src"); + } + return imageURL; + } + private void merge(final Offer offer) { synchronized (offer) { - offers.stream() - .filter(existing -> existing.getId().equals(offer.getId())) - .peek(existing -> existing.merge(offer)) - .findFirst() - .orElseGet(() -> { - log.info("Created: {}", offer); - offers.add(offer); - return offer; - }); + offers.stream().filter(existing -> existing.getId().equals(offer.getId())).peek(existing -> existing.merge(offer)).findFirst().orElseGet(() -> { + log.info("Created: {}", offer); + offers.add(offer); + return offer; + }); } } @@ -149,15 +159,7 @@ public class Kleinanzeigen { final Matcher localDateMatcher = Pattern.compile("(?\\d+).(?\\d+).(?\\d+)").matcher(text); if (localDateMatcher.find()) { - return ZonedDateTime.of( - LocalDate.of( - Integer.parseInt(localDateMatcher.group("day")), - Integer.parseInt(localDateMatcher.group("month")), - Integer.parseInt(localDateMatcher.group("year")) - ), - LocalTime.MIDNIGHT, - TimeZone.getDefault().toZoneId() - ); + return ZonedDateTime.of(LocalDate.of(Integer.parseInt(localDateMatcher.group("day")), Integer.parseInt(localDateMatcher.group("month")), Integer.parseInt(localDateMatcher.group("year"))), LocalTime.MIDNIGHT, TimeZone.getDefault().toZoneId()); } throw new NumberFormatException("Failed to parse date: " + text); } @@ -188,8 +190,10 @@ public class Kleinanzeigen { } } - private Optional findByTelegramMessageId(final MaybeInaccessibleMessage message) { - return offers.stream().filter(offer -> Objects.equals(offer.getTelegramMessageId(), message.getMessageId())).findFirst(); + public Optional findByTelegramMessageId(final MaybeInaccessibleMessage message) { + synchronized (offers) { + return offers.stream().filter(offer -> Objects.equals(offer.getTelegramMessageId(), message.getMessageId())).findFirst(); + } } } diff --git a/src/main/java/de/ph87/kleinanzeigen/api/Main.java b/src/main/java/de/ph87/kleinanzeigen/api/Main.java index e0c784e..05c7ae5 100644 --- a/src/main/java/de/ph87/kleinanzeigen/api/Main.java +++ b/src/main/java/de/ph87/kleinanzeigen/api/Main.java @@ -15,7 +15,7 @@ public class Main { private static final Kleinanzeigen kleinanzeigen = new Kleinanzeigen(offer -> bot.remove(List.of(offer))); public static void main(String[] args) throws IOException, TelegramApiException { - bot = new Bot(kleinanzeigen::ignore, kleinanzeigen::remember); + bot = new Bot(kleinanzeigen::ignore, kleinanzeigen::findByTelegramMessageId, kleinanzeigen::remember); try { while (true) { handle(bot); diff --git a/src/main/java/de/ph87/kleinanzeigen/api/Offer.java b/src/main/java/de/ph87/kleinanzeigen/api/Offer.java index 70d8a5a..097a13c 100644 --- a/src/main/java/de/ph87/kleinanzeigen/api/Offer.java +++ b/src/main/java/de/ph87/kleinanzeigen/api/Offer.java @@ -6,7 +6,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.Duration; import java.time.ZonedDateTime; @Getter @@ -43,9 +42,12 @@ public class Offer { private boolean ignore = false; - @Setter private boolean remember = false; + @Setter + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + private ZonedDateTime rememberUntil = null; + @JsonIgnore private boolean _deleted_ = false; @@ -72,17 +74,6 @@ public class Offer { } } - public String ageString() { - final Duration age = Duration.between(date, ZonedDateTime.now()); - if (age.toDays() > 0) { - return "%d Tag%s".formatted(age.toDays(), age.toDays() == 1 ? "" : "en"); - } else if (age.toHours() > 0) { - return "%d Stunde%s".formatted(age.toHours(), age.toHours() == 1 ? "" : "n"); - } else { - return "%d Minute%s".formatted(age.toMinutes(), age.toMinutes() == 1 ? "" : "n"); - } - } - public void merge(final Offer other) { verifyNotDeleted(); other.verifyNotDeleted(); @@ -125,6 +116,16 @@ public class Offer { public void ignore() { ignore = true; remember = false; + rememberUntil = null; + } + + public void setRemember(final boolean remember) { + if (remember) { + this.rememberUntil = null; + } else if (this.remember) { + this.rememberUntil = ZonedDateTime.now().plusHours(1); + } + this.remember = remember; } } diff --git a/src/main/java/de/ph87/kleinanzeigen/api/OfferParseException.java b/src/main/java/de/ph87/kleinanzeigen/api/OfferParseException.java index 1b70771..c0db26b 100644 --- a/src/main/java/de/ph87/kleinanzeigen/api/OfferParseException.java +++ b/src/main/java/de/ph87/kleinanzeigen/api/OfferParseException.java @@ -4,7 +4,7 @@ import org.jsoup.nodes.Element; public class OfferParseException extends Exception { - public OfferParseException(final Element element, final RuntimeException cause) { + public OfferParseException(final Element element, final Exception cause) { super(element.outerHtml(), cause); }