From 69d38975f0015b1b905db35c77aff7078353d9b8 Mon Sep 17 00:00:00 2001 From: Patrick Reinhart Date: Sun, 5 Mar 2023 11:30:12 +0100 Subject: [PATCH] Adds initial mastodon status stream support - listen to status updates containing defined hash tags - listen to status messages of defined users Signed-off-by: Patrick Reinhart --- .../cache/URLContentCacheBase.java | 4 +- tweet-impl-mastodon4j/build.gradle | 6 +- .../impl/mastodon4j/AccountPredicate.java | 58 ++++ .../impl/mastodon4j/EventStatusConsumer.java | 72 +++++ .../impl/mastodon4j/MastodonAccount.java | 78 ++++++ .../tweet/impl/mastodon4j/MastodonStatus.java | 164 +++++++++++ .../impl/mastodon4j/MastodonTweeter.java | 162 ++++++++++- .../tweet/impl/mastodon4j/StatusStream.java | 56 ++++ .../impl/mastodon4j/UserMentionPredicate.java | 61 ++++ .../impl/mastodon4j/AccountPredicateTest.java | 75 +++++ .../mastodon4j/EventStatusConsumerTest.java | 100 +++++++ .../impl/mastodon4j/MastodonAccountTest.java | 102 +++++++ .../impl/mastodon4j/MastodonEntities.java | 67 +++++ .../impl/mastodon4j/MastodonStatusTest.java | 161 +++++++++++ .../impl/mastodon4j/MastodonTweeterTest.java | 262 ++++++++++++++++++ .../impl/mastodon4j/StatusStreamTest.java | 82 ++++++ .../mastodon4j/UserMentionPredicateTest.java | 74 +++++ 17 files changed, 1574 insertions(+), 10 deletions(-) create mode 100644 tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/AccountPredicate.java create mode 100644 tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumer.java create mode 100644 tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccount.java create mode 100644 tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatus.java create mode 100644 tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStream.java create mode 100644 tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicate.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/AccountPredicateTest.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumerTest.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccountTest.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonEntities.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatusTest.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeterTest.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStreamTest.java create mode 100644 tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicateTest.java diff --git a/cache/src/main/java/org/tweetwallfx/cache/URLContentCacheBase.java b/cache/src/main/java/org/tweetwallfx/cache/URLContentCacheBase.java index 148d506f6..d3a85937c 100644 --- a/cache/src/main/java/org/tweetwallfx/cache/URLContentCacheBase.java +++ b/cache/src/main/java/org/tweetwallfx/cache/URLContentCacheBase.java @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2018-2022 TweetWallFX + * Copyright (c) 2018-2023 TweetWallFX * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -139,6 +139,7 @@ public final URLContent getCachedOrLoad(final String urlString) { * @param contentConsumer the Consumer processing the content */ public final void getCachedOrLoad(final String urlString, final Consumer contentConsumer) { + Objects.requireNonNull(urlString, "urlString must not be null"); Objects.requireNonNull(contentConsumer, "contentConsumer must not be null"); contentLoader.execute(() -> { @@ -152,6 +153,7 @@ public final void getCachedOrLoad(final String urlString, final Consumer { + private static final Logger LOGGER = LoggerFactory.getLogger(AccountPredicate.class); + private final Set userList; + + public AccountPredicate(List userList) { + this.userList = Objects.requireNonNull(userList, "userList must not be null").stream() + .map(user -> user.substring(1)) + .collect(Collectors.toCollection(() -> new TreeSet<>(String::compareToIgnoreCase))); + } + + @Override + public boolean test(Status status) { + final String username = status.account().username(); + if (userList.contains(username)) { + return true; + } else { + LOGGER.debug("No matching users {} for account {}", userList, username); + return false; + } + } +} diff --git a/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumer.java b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumer.java new file mode 100644 index 000000000..ce331bd89 --- /dev/null +++ b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumer.java @@ -0,0 +1,72 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; +import org.mastodon4j.core.api.entities.Event; +import org.mastodon4j.core.api.entities.Status; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; + +public class EventStatusConsumer implements Consumer { + private static final Logger LOGGER = LoggerFactory.getLogger(EventStatusConsumer.class); + private final Consumer statusConsumer; + private final Predicate statusPredicate; + + EventStatusConsumer(Consumer statusConsumer) { + this(statusConsumer, status -> true); + } + + EventStatusConsumer(Consumer statusConsumer, Predicate statusPredicate) { + this.statusConsumer = Objects.requireNonNull(statusConsumer, "statusConsumer must not be null"); + this.statusPredicate = Objects.requireNonNull(statusPredicate, "statusPredicate must not be null"); + } + + private void notifyStatusPayload(String payload) { + LOGGER.debug("Processing payload:\n{}", payload); + try (Jsonb jsonb = JsonbBuilder.create()) { + final Status status = jsonb.fromJson(payload, Status.class); + if (statusPredicate.test(status)) { + statusConsumer.accept(status); + } else { + LOGGER.debug("Status {} not matching criteria", status.id()); + } + } catch (Exception e) { + LOGGER.error("Failed to notify status", e); + } + } + + @Override + public void accept(Event event) { + switch (event.event()) { + case "update", "status.update" -> notifyStatusPayload(event.payload()); + default -> LOGGER.debug("Ignoring event:\n{}", event); + } + } +} diff --git a/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccount.java b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccount.java new file mode 100644 index 000000000..3e2d4065e --- /dev/null +++ b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccount.java @@ -0,0 +1,78 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.mastodon4j.core.api.entities.Account; +import org.mastodon4j.core.api.entities.Field; +import org.tweetwallfx.tweet.api.User; + +import java.util.Objects; + +public class MastodonAccount implements User { + private final Account account; + + public MastodonAccount(Account account) { + this.account = account; + } + + @Override + public String getBiggerProfileImageUrl() { + return getProfileImageUrl(); + } + + @Override + public long getId() { + return Long.parseLong(account.id()); + } + + @Override + public String getLang() { + return "not-supported"; + } + + @Override + public String getName() { + return account.username(); + } + + @Override + public String getProfileImageUrl() { + return account.avatar(); + } + + @Override + public String getScreenName() { + return account.display_name(); + } + + @Override + public int getFollowersCount() { + return account.followers_count(); + } + + @Override + public boolean isVerified() { + return account.fields().stream().map(Field::verified_at).anyMatch(Objects::nonNull); + } +} diff --git a/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatus.java b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatus.java new file mode 100644 index 000000000..0cc043a82 --- /dev/null +++ b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatus.java @@ -0,0 +1,164 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.jsoup.Jsoup; +import org.jsoup.safety.Cleaner; +import org.jsoup.safety.Safelist; +import org.mastodon4j.core.api.entities.Status; +import org.tweetwallfx.tweet.api.Tweet; +import org.tweetwallfx.tweet.api.User; +import org.tweetwallfx.tweet.api.entry.HashtagTweetEntry; +import org.tweetwallfx.tweet.api.entry.MediaTweetEntry; +import org.tweetwallfx.tweet.api.entry.SymbolTweetEntry; +import org.tweetwallfx.tweet.api.entry.UrlTweetEntry; +import org.tweetwallfx.tweet.api.entry.UserMentionTweetEntry; + +import java.time.LocalDateTime; +import java.util.Optional; + +public class MastodonStatus implements Tweet { + private final Status status; + + MastodonStatus(Status status) { + this.status = status; + } + + @Override + public LocalDateTime getCreatedAt() { + return status.created_at().toLocalDateTime(); + } + + @Override + public int getFavoriteCount() { + return status.favourites_count(); + } + + @Override + public long getId() { + return Long.parseLong(status.id()); + } + + @Override + public long getInReplyToTweetId() { + final String inReplyToId = status.in_reply_to_id(); + if (inReplyToId == null) { + return 0; + } + return Long.parseLong(inReplyToId); + } + + @Override + public long getInReplyToUserId() { + final String inReplyToAccountId = status.in_reply_to_account_id(); + if (inReplyToAccountId == null) { + return 0; + } + return Long.parseLong(inReplyToAccountId); + } + + @Override + public String getInReplyToScreenName() { + return null; + } + + @Override + public String getLang() { + return status.language(); + } + + @Override + public int getRetweetCount() { + return status.reblogs_count(); + } + + @Override + public Tweet getRetweetedTweet() { + return Optional.ofNullable(status.reblog()).map(MastodonStatus::new).orElse(null); + } + + @Override + public Tweet getOriginTweet() { + return Optional.ofNullable(status.reblog()).map(MastodonStatus::new).orElse(this); + } + + @Override + public String getText() { + Cleaner cleaner = new Cleaner(Safelist.none()); + return cleaner.clean(Jsoup.parse(status.content())).text(); + } + + @Override + public User getUser() { + return new MastodonAccount(status.account()); + } + + @Override + public boolean isRetweet() { + return Boolean.TRUE.equals(status.reblogged()); + } + + @Override + public boolean isTruncated() { + return false; + } + + @Override + public HashtagTweetEntry[] getHashtagEntries() { + return new HashtagTweetEntry[0]; + } + + @Override + public MediaTweetEntry[] getMediaEntries() { + return new MediaTweetEntry[0]; + } + + @Override + public SymbolTweetEntry[] getSymbolEntries() { + return new SymbolTweetEntry[0]; + } + + @Override + public UrlTweetEntry[] getUrlEntries() { + return new UrlTweetEntry[0]; + } + + @Override + public UserMentionTweetEntry[] getUserMentionEntries() { + return new UserMentionTweetEntry[0]; + } + + @Override + public int hashCode() { + return status.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof MastodonStatus mastodonStatus) { + return status.equals(mastodonStatus.status); + } + return false; + } +} diff --git a/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeter.java b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeter.java index 1a55b4fd4..259ca1e1a 100644 --- a/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeter.java +++ b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeter.java @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2015-2023 TweetWallFX + * Copyright (c) 2023 TweetWallFX * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -23,6 +23,14 @@ */ package org.tweetwallfx.tweet.impl.mastodon4j; +import org.mastodon4j.core.MastodonClient; +import org.mastodon4j.core.MastodonException; +import org.mastodon4j.core.api.BaseMastodonApi; +import org.mastodon4j.core.api.EventStream; +import org.mastodon4j.core.api.MastodonApi; +import org.mastodon4j.core.api.entities.Status; +import org.mastodon4j.core.api.entities.AccessToken; +import org.mastodon4j.core.api.entities.Subscription; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tweetwallfx.config.Configuration; @@ -34,39 +42,129 @@ import org.tweetwallfx.tweet.api.User; import org.tweetwallfx.tweet.impl.mastodon4j.config.MastodonSettings; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.mastodon4j.core.api.BaseMastodonApi.QueryOptions.Type.ACCOUNTS; +import static org.mastodon4j.core.api.BaseMastodonApi.QueryOptions.Type.HASHTAGS; import static org.tweetwallfx.tweet.impl.mastodon4j.config.MastodonSettings.CONFIG_KEY; public class MastodonTweeter implements Tweeter { private static final Logger LOGGER = LoggerFactory.getLogger(MastodonTweeter.class); - static final MastodonSettings MASTODON_SETTINGS = Configuration.getInstance().getConfigTyped(CONFIG_KEY, MastodonSettings.class); + private static final Pattern ACCEPTED_KEYWORDS = Pattern.compile("([@#]).+"); + private static final Pattern KEYWORD_DELEMITER = Pattern.compile(" +"); + + private final MastodonSettings settings; + private final MastodonApi client; + private final List openStreams; + private final AccessToken accessToken; public MastodonTweeter() { - LOGGER.debug("Initializing with configuration: {}", MASTODON_SETTINGS); + this(Configuration.getInstance().getConfigTyped(CONFIG_KEY, MastodonSettings.class), MastodonTweeter::createClient); + } + + MastodonTweeter(MastodonSettings settings, Function clientCreator) { + LOGGER.debug("Initializing with configuration: {}", settings); + this.settings = settings; + this.accessToken = AccessToken.create(settings.oauth().accessToken()); + this.client = clientCreator.apply(settings); + this.openStreams = new ArrayList<>(); + } + + static MastodonApi createClient(MastodonSettings settings) { + return MastodonClient.create(settings.restUrl(), AccessToken.create(settings.oauth().accessToken())); } @Override public boolean isEnabled() { - return MASTODON_SETTINGS.enabled(); + return settings.enabled(); } @Override public TweetStream createTweetStream(TweetFilterQuery filterQuery) { LOGGER.debug("createTweetStream({})", filterQuery); - return tweetConsumer -> LOGGER.debug("onTweet({})", tweetConsumer); + final StatusStream statusStream = new StatusStream(); + final Map> trackTypeListMap = Stream.of(filterQuery.getTrack()) + .collect(Collectors.groupingBy(this::trackType, () -> new EnumMap<>(TrackType.class), Collectors.toList())); + trackTypeListMap.forEach((trackType, values) -> { + switch (trackType) { + case HASHTAG -> handleHashtags(statusStream, values); + case USER -> handleUsers(statusStream, values); + case UNKNOWN -> LOGGER.error("Track names not supported: {}", values); + } + }); + return statusStream; + } + + enum TrackType { + UNKNOWN, HASHTAG, USER; + } + + private TrackType trackType(String trackName) { + if (trackName.startsWith("#")) { + return TrackType.HASHTAG; + } else if (trackName.startsWith("@")) { + return TrackType.USER; + } else { + return TrackType.UNKNOWN; + } + } + + EventStream createRegisteredStream() { + final EventStream stream = client.streaming().stream(); + openStreams.add(stream); + return stream; + } + + private void handleHashtags(StatusStream statusStream, List hashtags) { + final EventStream stream = createRegisteredStream(); + stream.registerConsumer(new EventStatusConsumer(statusStream)); + hashtags.stream() + .map(hashtag -> Subscription.hashtag(true, accessToken, hashtag.substring(1))) + .forEach(stream::changeSubscription); + } + private void handleUsers(StatusStream statusStream, List users) { + final EventStream stream = createRegisteredStream(); + final Predicate predicate = new UserMentionPredicate(users).or(new AccountPredicate(users)); + stream.registerConsumer(new EventStatusConsumer(statusStream, predicate)); + stream.changeSubscription(Subscription.stream(true, accessToken, "public")); } @Override public Tweet getTweet(long tweetId) { LOGGER.debug("getTweet({})", tweetId); - return null; + try { + return Optional.ofNullable(client.statuses().get(Long.toString(tweetId))) + .map(MastodonStatus::new) + .orElse(null); + } catch (RuntimeException e) { + LOGGER.error("Unexpected failure on backend", e); + return null; + } } @Override public User getUser(String userId) { LOGGER.debug("getUser({})", userId); - return null; + try { + return Optional.ofNullable(client.accounts().get(userId)) + .map(MastodonAccount::new) + .orElse(null); + } catch (RuntimeException e) { + LOGGER.error("Unexpected failure on backend", e); + return null; + } } @Override @@ -108,17 +206,65 @@ public Stream getFollowers(long userId) { @Override public Stream search(TweetQuery tweetQuery) { LOGGER.debug("search({})", tweetQuery); - return Stream.empty(); + return KEYWORD_DELEMITER.splitAsStream(tweetQuery.getQuery()) + .flatMap(keyword -> queryStatuses(keyword, null)) + .map(MastodonStatus::new); } @Override public Stream searchPaged(TweetQuery tweetQuery, int numberOfPages) { LOGGER.debug("searchPaged({}, {})", tweetQuery, numberOfPages); + return KEYWORD_DELEMITER.splitAsStream(tweetQuery.getQuery()) + .flatMap(keyword -> queryStatuses(keyword, numberOfPages)) + .map(MastodonStatus::new); + } + + private Stream queryStatuses(String keyword, Integer numberOfPages) { + final Matcher matcher = ACCEPTED_KEYWORDS.matcher(keyword); + if (matcher.matches()) { + final BaseMastodonApi.QueryOptions queryOptions = BaseMastodonApi.QueryOptions.of(keyword); + return switch (matcher.group(1)) { + case "#" -> queryHashtag(numberOfPages == null ? queryOptions : queryOptions.limit(numberOfPages)); + case "@" -> queryAccount(numberOfPages == null ? queryOptions : queryOptions.limit(numberOfPages)); + default -> Stream.empty(); + }; + } return Stream.empty(); } + private Stream queryAccount(BaseMastodonApi.QueryOptions queryOptions) { + try { + return client.search(queryOptions.type(ACCOUNTS)).accounts().stream() + .flatMap(account -> client.accounts().statuses(account.id()).stream()); + } catch (RuntimeException e) { + LOGGER.error("Unexpected failure on backend", e); + return Stream.empty(); + } + } + + private Stream queryHashtag(BaseMastodonApi.QueryOptions queryOptions) { + try { + return client.search(queryOptions.type(HASHTAGS)).hashtags().stream() + .flatMap(hashtag -> client.timelines().tag(hashtag.name()).stream()); + } catch (RuntimeException e) { + LOGGER.error("Unexpected failure on backend", e); + return Stream.empty(); + } + } + @Override public void shutdown() { LOGGER.debug("shutdown()"); + openStreams.removeIf(MastodonTweeter::closeStream); + } + + private static boolean closeStream(EventStream stream) { + try { + stream.close(); + LOGGER.info("Closed stream {}", stream); + } catch (MastodonException e) { + LOGGER.error("Error wile closing stream {}", stream, e); + } + return true; } } diff --git a/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStream.java b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStream.java new file mode 100644 index 000000000..be3b6be0a --- /dev/null +++ b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStream.java @@ -0,0 +1,56 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.mastodon4j.core.api.entities.Status; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tweetwallfx.tweet.api.Tweet; +import org.tweetwallfx.tweet.api.TweetStream; + +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +public final class StatusStream implements TweetStream, Consumer { + private static final Logger LOGGER = LoggerFactory.getLogger(StatusStream.class); + + private final CopyOnWriteArrayList> consumers; + + StatusStream() { + consumers = new CopyOnWriteArrayList<>(); + } + + @Override + public void onTweet(Consumer tweetConsumer) { + LOGGER.debug("onTweet({})", tweetConsumer); + consumers.add(tweetConsumer); + } + + @Override + public void accept(Status status) { + LOGGER.debug("Notify status:\n{}", status); + final MastodonStatus mastodonStatus = new MastodonStatus(status); + consumers.forEach(tweetConsumer -> tweetConsumer.accept(mastodonStatus)); + } +} diff --git a/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicate.java b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicate.java new file mode 100644 index 000000000..b11932f28 --- /dev/null +++ b/tweet-impl-mastodon4j/src/main/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicate.java @@ -0,0 +1,61 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.mastodon4j.core.api.entities.Status; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public class UserMentionPredicate implements Predicate { + private static final Logger LOGGER = LoggerFactory.getLogger(UserMentionPredicate.class); + private final Set userList; + + UserMentionPredicate(List userList) { + this.userList = Objects.requireNonNull(userList, "userList must not be null").stream() + .map(user -> user.substring(1)) + .collect(Collectors.toCollection(() -> new TreeSet<>(String::compareToIgnoreCase))); + } + + private boolean isMentionedUser(Status.Mention mention) { + return userList.contains(mention.username()); + } + + @Override + public boolean test(Status status) { + final List mentions = status.mentions(); + if (mentions.stream().anyMatch(this::isMentionedUser)) { + return true; + } else { + LOGGER.debug("No matching users {} mentioned in {}", userList, mentions); + return false; + } + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/AccountPredicateTest.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/AccountPredicateTest.java new file mode 100644 index 000000000..bd38750db --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/AccountPredicateTest.java @@ -0,0 +1,75 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mastodon4j.core.api.entities.Account; +import org.mastodon4j.core.api.entities.Status; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.slf4j.Logger; + +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createAccount; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createStatus; + +@MockitoSettings +public class AccountPredicateTest { + @Mock(name = "org.tweetwallfx.tweet.impl.mastodon4j.AccountPredicate") + Logger logger; + + AccountPredicate predicate; + + @BeforeEach + void prepare() { + predicate = new AccountPredicate(List.of("@reinhapa", "@devoxx")); + } + + @AfterEach + void verifyMocks() { + verifyNoMoreInteractions(logger); + } + + @Test + void test() { + final Account account = createAccount("xx", "JohnDoe"); + Status statusOne = createStatus("1", "bli", account); + Set filterList = new TreeSet<>(String::compareToIgnoreCase); + filterList.addAll(List.of("devoxx","reinhapa")); + + doNothing().when(logger).debug("No matching users {} for account {}", filterList, "JohnDoe"); + assertThat(predicate.test(statusOne)).isFalse(); + + Status statusTwo = createStatus("2", "bla", createAccount("42","REINHAPA")); + assertThat(predicate.test(statusTwo)).isTrue(); + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumerTest.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumerTest.java new file mode 100644 index 000000000..ccd678d25 --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/EventStatusConsumerTest.java @@ -0,0 +1,100 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mastodon4j.core.api.entities.Event; +import org.mastodon4j.core.api.entities.Status; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.slf4j.Logger; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.isNotNull; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createStatus; + +@MockitoSettings +class EventStatusConsumerTest { + @Mock(name = "org.tweetwallfx.tweet.impl.mastodon4j.EventStatusConsumer") + Logger logger; + @Mock(name = "statusConsumer") + Consumer statusConsumer; + EventStatusConsumer eventStatusConsumer; + + @BeforeEach + void prepare() { + eventStatusConsumer = new EventStatusConsumer(statusConsumer); + } + + @AfterEach + void verifyMocks() { + verifyNoMoreInteractions(logger, statusConsumer); + } + + @ParameterizedTest + @ValueSource(strings = {"delete", "notification", "filters_changed", "conversation", "announcement", + "announcement.reaction", "announcement.delete", "encrypted_message"}) + void acceptUnsupportedEventTypes(String eventType) { + final Event event = new Event(null, eventType, null); + doNothing().when(logger).debug("Ignoring event:\n{}", event); + assertThatNoException().isThrownBy(() -> eventStatusConsumer.accept(event)); + } + + @Test + void acceptIllegalStatusPayload() { + final Event event = new Event(List.of(), "update", "illegal-payload"); + doNothing().when(logger).debug("Processing payload:\n{}", "illegal-payload"); + doNothing().when(logger).error(eq("Failed to notify status"), isNotNull(Throwable.class)); + assertThatNoException().isThrownBy(() -> eventStatusConsumer.accept(event)); + } + + @Test + void acceptValidPayload() { + final String payload = "{\"id\":\"42\",\"content\":\"gugus\",\"mentions\":[]}"; + final Event event = new Event(List.of(), "update", payload); + doNothing().when(logger).debug("Processing payload:\n{}", payload); + doNothing().when(statusConsumer).accept(createStatus("42", "gugus")); + assertThatNoException().isThrownBy(() -> eventStatusConsumer.accept(event)); + } + + @Test + void acceptValidPayloadNotMatching() { + final Event event = new Event(List.of(), "update", "{\"id\":\"43\",\"content\":\"gaga\"}"); + doNothing().when(logger).debug("Processing payload:\n{}", "{\"id\":\"43\",\"content\":\"gaga\"}"); + doNothing().when(logger).debug("Status {} not matching criteria", "43"); + + eventStatusConsumer = new EventStatusConsumer(statusConsumer, status -> "42".equals(status.id())); + assertThatNoException().isThrownBy(() -> eventStatusConsumer.accept(event)); + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccountTest.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccountTest.java new file mode 100644 index 000000000..bf3a4835f --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonAccountTest.java @@ -0,0 +1,102 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.junit.jupiter.api.Test; +import org.mastodon4j.core.api.entities.Account; +import org.mastodon4j.core.api.entities.Field; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MastodonAccountTest { + MastodonAccount accountOne = new MastodonAccount(new Account("1", "userName1", null, null, + "displayName1", null, "avatar1", null, null, null, null, + List.of(), null, null, null, null, null, null, null, + null, null, null, null, 11, null)); + MastodonAccount accountTwo = new MastodonAccount(new Account("2", "userName2", null, null, + "displayName2", null, "avatar2", null, null, null, null, + fields(), null, null, null, null, null, null, null, + null, null, null, null, 22, null)); + + @Test + void getBiggerProfileImageUrl() { + assertThat(accountOne.getBiggerProfileImageUrl()).isEqualTo("avatar1"); + assertThat(accountTwo.getBiggerProfileImageUrl()).isEqualTo("avatar2"); + } + + @Test + void getId() { + assertThat(accountOne.getId()).isEqualTo(1L); + assertThat(accountTwo.getId()).isEqualTo(2L); + } + + @Test + void getLang() { + assertThat(accountOne.getLang()).isEqualTo("not-supported"); + assertThat(accountTwo.getLang()).isEqualTo("not-supported"); + } + + @Test + void getName() { + assertThat(accountOne.getName()).isEqualTo("userName1"); + assertThat(accountTwo.getName()).isEqualTo("userName2"); + } + + @Test + void getProfileImageUrl() { + assertThat(accountOne.getProfileImageUrl()).isEqualTo("avatar1"); + assertThat(accountTwo.getProfileImageUrl()).isEqualTo("avatar2"); + } + + @Test + void getScreenName() { + assertThat(accountOne.getScreenName()).isEqualTo("displayName1"); + assertThat(accountTwo.getScreenName()).isEqualTo("displayName2"); + } + + @Test + void getFollowersCount() { + assertThat(accountOne.getFollowersCount()).isEqualTo(11); + assertThat(accountTwo.getFollowersCount()).isEqualTo(22); + } + + @Test + void isVerified() { + assertThat(accountOne.isVerified()).isFalse(); + assertThat(accountTwo.isVerified()).isTrue(); + } + + private static List fields() { + List fields = new ArrayList<>(); + fields.add(new Field("fieldOne", "valueOne", null)); + fields.add(new Field("fieldTwo", "valueTwo", ZonedDateTime.now(ZoneId.systemDefault()))); + fields.add(new Field("fieldThree", "valueThree", null)); + return fields; + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonEntities.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonEntities.java new file mode 100644 index 000000000..1339c5930 --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonEntities.java @@ -0,0 +1,67 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.mastodon4j.core.api.entities.Account; +import org.mastodon4j.core.api.entities.Search; +import org.mastodon4j.core.api.entities.Status; + +import java.util.List; + +public class MastodonEntities { + public static Account createAccount(String id, String userName) { + return new Account(id, userName, null, null, null, null, null, + null, null, null, null, null, null, null, + null, null, null, null, null, null, null, + null, null, null, null); + } + + public static Status.Mention createMention(String id, String username) { + return new Status.Mention(id, username, null, username); + } + + public static Status createStatus(String id, String content) { + return createStatus(id, content, null, List.of()); + } + + public static Status createStatus(String id, String content, Account account) { + return createStatus(id, content, account, List.of()); + } + + public static Status createStatus(String id, String content, List mentions) { + return createStatus(id, content, null, mentions); + } + + public static Status createStatus(String id, String content, Account account, List mentions) { + return new Status(id, null, null, account, content, null, null, + null, null, null, mentions, null, null, + null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null); + } + + public static Search createSearch() { + return new Search(List.of(), List.of(), List.of()); + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatusTest.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatusTest.java new file mode 100644 index 000000000..d1783300f --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonStatusTest.java @@ -0,0 +1,161 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.junit.jupiter.api.Test; +import org.mastodon4j.core.api.entities.Status; + +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class MastodonStatusTest { + ZonedDateTime createdAt = ZonedDateTime.now(ZoneId.systemDefault()); + MastodonStatus status = new MastodonStatus(new Status("42", null, createdAt, null, + "

the status message html

", null, null, null, null, + null, null, null, null, 33, 22, null, + null, null, null, null, null, null, "german", + null, null, null, true, null, null, null, null)); + + MastodonStatus statusWithoutOptionals = new MastodonStatus(new Status("43", null, createdAt, null, + "", null, null, null, null, null, null, + null, null, 34, 23, null, null, null, + null, null, null, null, "english", null, null, + null, null, null, null, null, null)); + + @Test + void getCreatedAt() { + assertThat(status.getCreatedAt()).isEqualTo(createdAt.toLocalDateTime()); + assertThat(statusWithoutOptionals.getCreatedAt()).isEqualTo(createdAt.toLocalDateTime()); + } + + @Test + void getFavoriteCount() { + assertThat(status.getFavoriteCount()).isEqualTo(22); + assertThat(statusWithoutOptionals.getFavoriteCount()).isEqualTo(23); + } + + @Test + void getId() { + assertThat(status.getId()).isEqualTo(42L); + assertThat(statusWithoutOptionals.getId()).isEqualTo(43L); + } + + @Test + void getInReplyToTweetId() { + assertThat(status.getInReplyToTweetId()).isEqualTo(0); + assertThat(statusWithoutOptionals.getInReplyToTweetId()).isEqualTo(0); + } + + @Test + void getInReplyToUserId() { + assertThat(status.getInReplyToUserId()).isEqualTo(0); + assertThat(statusWithoutOptionals.getInReplyToUserId()).isEqualTo(0); + } + + @Test + void getInReplyToScreenName() { + assertThat(status.getInReplyToScreenName()).isNull(); + assertThat(statusWithoutOptionals.getInReplyToScreenName()).isNull(); + } + + @Test + void getLang() { + assertThat(status.getLang()).isEqualTo("german"); + assertThat(statusWithoutOptionals.getLang()).isEqualTo("english"); + } + + @Test + void getRetweetCount() { + assertThat(status.getRetweetCount()).isEqualTo(33); + assertThat(statusWithoutOptionals.getRetweetCount()).isEqualTo(34); + } + + @Test + void getRetweetedTweet() { + assertThat(status.getRetweetedTweet()).isNull(); + assertThat(statusWithoutOptionals.getRetweetedTweet()).isNull(); + } + + @Test + void getOriginTweet() { + assertThat(status.getOriginTweet()).isSameAs(status); + assertThat(statusWithoutOptionals.getOriginTweet()).isSameAs(statusWithoutOptionals); + } + + @Test + void getText() { + assertThat(status.getText()).isEqualTo("the status message html"); + assertThat(statusWithoutOptionals.getText()).isEmpty(); + } + + @Test + void getUser() { + assertThat(status.getUser()).isInstanceOf(MastodonAccount.class); + assertThat(statusWithoutOptionals.getUser()).isInstanceOf(MastodonAccount.class); + } + + @Test + void isRetweet() { + assertThat(status.isRetweet()).isTrue(); + assertThat(statusWithoutOptionals.isRetweet()).isFalse(); + } + + @Test + void isTruncated() { + assertThat(status.isTruncated()).isFalse(); + assertThat(statusWithoutOptionals.isTruncated()).isFalse(); + } + + @Test + void getHashtagEntries() { + assertThat(status.getHashtagEntries()).isNotNull().isEmpty(); + assertThat(statusWithoutOptionals.getHashtagEntries()).isNotNull().isEmpty(); + } + + @Test + void getMediaEntries() { + assertThat(status.getMediaEntries()).isNotNull().isEmpty(); + assertThat(statusWithoutOptionals.getMediaEntries()).isNotNull().isEmpty(); + } + + @Test + void getSymbolEntries() { + assertThat(status.getSymbolEntries()).isNotNull().isEmpty(); + assertThat(statusWithoutOptionals.getSymbolEntries()).isNotNull().isEmpty(); + } + + @Test + void getUrlEntries() { + assertThat(status.getUrlEntries()).isNotNull().isEmpty(); + assertThat(statusWithoutOptionals.getUrlEntries()).isNotNull().isEmpty(); + } + + @Test + void getUserMentionEntries() { + assertThat(status.getUserMentionEntries()).isNotNull().isEmpty(); + assertThat(statusWithoutOptionals.getUserMentionEntries()).isNotNull().isEmpty(); + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeterTest.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeterTest.java new file mode 100644 index 000000000..1c49ce6c9 --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/MastodonTweeterTest.java @@ -0,0 +1,262 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mastodon4j.core.MastodonClient; +import org.mastodon4j.core.MastodonException; +import org.mastodon4j.core.api.Accounts; +import org.mastodon4j.core.api.BaseMastodonApi; +import org.mastodon4j.core.api.EventStream; +import org.mastodon4j.core.api.MastodonApi; +import org.mastodon4j.core.api.Statuses; +import org.mastodon4j.core.api.Streaming; +import org.mastodon4j.core.api.entities.AccessToken; +import org.mastodon4j.core.api.entities.Event; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.slf4j.Logger; +import org.tweetwallfx.tweet.api.TweetFilterQuery; +import org.tweetwallfx.tweet.api.TweetQuery; +import org.tweetwallfx.tweet.api.User; +import org.tweetwallfx.tweet.impl.mastodon4j.config.MastodonSettings; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mastodon4j.core.api.BaseMastodonApi.QueryOptions.Type.ACCOUNTS; +import static org.mastodon4j.core.api.BaseMastodonApi.QueryOptions.Type.HASHTAGS; +import static org.mastodon4j.core.api.entities.Subscription.hashtag; +import static org.mastodon4j.core.api.entities.Subscription.stream; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createAccount; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createSearch; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createStatus; + +@MockitoSettings +class MastodonTweeterTest { + public static final String ACCESS_TOKEN_VALUE = "accessTokenValue"; + @Mock(name = "org.tweetwallfx.tweet.impl.mastodon4j.MastodonTweeter") + Logger logger; + @Mock(name = "client") + MastodonApi client; + @Mock(name = "statuses") + Statuses statuses; + @Mock(name = "accounts") + Accounts accounts; + @Mock(name = "user") + User user; + @Mock(name = "streaming") + Streaming streaming; + @Mock(name = "eventStream") + EventStream eventStream; + @Mock(name = "tweetQuery") + TweetQuery tweetQuery; + @Mock(name = "filterQuery") + TweetFilterQuery filterQuery; + MastodonSettings settings; + MastodonTweeter tweeter; + + @BeforeEach + void prepare() { + MastodonSettings.OAuth oauth = new MastodonSettings.OAuth(ACCESS_TOKEN_VALUE); + settings = new MastodonSettings(false, true, "https://mastodon.social", oauth); + tweeter = new MastodonTweeter(settings, s -> client); + + verify(logger).debug("Initializing with configuration: {}", settings); + } + + @AfterEach + void verifyMocks() { + verifyNoMoreInteractions(logger, client, statuses, accounts, user, streaming, eventStream, tweetQuery, filterQuery); + } + + @Test + void createClient() { + assertThat(MastodonTweeter.createClient(settings)).isInstanceOf(MastodonClient.class); + } + + @Test + void isEnabled() { + assertThat(tweeter.isEnabled()).isTrue(); + MastodonSettings.OAuth oauth = new MastodonSettings.OAuth(ACCESS_TOKEN_VALUE); + MastodonSettings disabledSettings = new MastodonSettings(false, false, null, oauth); + MastodonTweeter disabledTweeter = new MastodonTweeter(disabledSettings, s -> client); + + assertThat(disabledTweeter.isEnabled()).isFalse(); + verify(logger).debug("Initializing with configuration: {}", disabledSettings); + } + + @Test + void getTweet() { + doNothing().when(logger).debug("getTweet({})", 123L); + when(client.statuses()).thenReturn(statuses); + when(statuses.get("123")).thenReturn(createStatus("123", "some message")); + + assertThat(tweeter.getTweet(123)).isNotNull().satisfies(tweet -> { + assertThat(tweet.getId()).isEqualTo(123L); + assertThat(tweet.getText()).isEqualTo("some message"); + }); + } + + @Test + void getUser() { + doNothing().when(logger).debug("getUser({})", "42"); + when(client.accounts()).thenReturn(accounts); + when(accounts.get("42")).thenReturn(createAccount("42", "johnDoe")); + + assertThat(tweeter.getUser("42")).isNotNull().satisfies(user -> { + assertThat(user.getId()).isEqualTo(42L); + assertThat(user.getName()).isEqualTo("johnDoe"); + }); + } + + @Test + void getFriendsForUser() { + doNothing().when(logger).debug("getFriends({})", user); + + assertThat(tweeter.getFriends(user)).isEmpty(); + } + + @Test + void getFriendsForUserScreenName() { + doNothing().when(logger).debug("getFriends({})", "userScreenName"); + + assertThat(tweeter.getFriends("userScreenName")).isEmpty(); + } + + @Test + void getFriendsForUserId() { + doNothing().when(logger).debug("getFriends({})", 4711L); + + assertThat(tweeter.getFriends(4711L)).isEmpty(); + } + + @Test + void getFollowersForUser() { + doNothing().when(logger).debug("getFollowers({})", user); + + assertThat(tweeter.getFollowers(user)).isEmpty(); + } + + @Test + void getFollowersForUserScreenName() { + doNothing().when(logger).debug("getFollowers({})", "userScreenName"); + + assertThat(tweeter.getFollowers("userScreenName")).isEmpty(); + } + + @Test + void getFollowersForUserId() { + doNothing().when(logger).debug("getFollowers({})", 4711L); + + assertThat(tweeter.getFollowers(4711L)).isEmpty(); + } + + @Test + void search() { + doNothing().when(logger).debug("search({})", tweetQuery); + when(tweetQuery.getQuery()).thenReturn("#javaIsFun or @TweetWallFx OR @reinhapa"); + when(client.search(BaseMastodonApi.QueryOptions.of("#javaIsFun").type(HASHTAGS))).thenReturn(createSearch()); + when(client.search(BaseMastodonApi.QueryOptions.of("@TweetWallFx").type(ACCOUNTS))).thenReturn(createSearch()); + when(client.search(BaseMastodonApi.QueryOptions.of("@reinhapa").type(ACCOUNTS))).thenReturn(createSearch()); + + assertThat(tweeter.search(tweetQuery)).isEmpty(); + } + + @Test + void searchPaged() { + doNothing().when(logger).debug("searchPaged({}, {})", tweetQuery, 22); + when(tweetQuery.getQuery()).thenReturn("#javaIsFun @reinhapa"); + when(client.search(BaseMastodonApi.QueryOptions.of("#javaIsFun").type(HASHTAGS).limit(22))).thenReturn(createSearch()); + when(client.search(BaseMastodonApi.QueryOptions.of("@reinhapa").type(ACCOUNTS).limit(22))).thenReturn(createSearch()); + + assertThat(tweeter.searchPaged(tweetQuery, 22)).isEmpty(); + verify(logger).debug("Initializing with configuration: {}", settings); + } + + @Test + void shutdown() { + doNothing().when(logger).debug("shutdown()"); + + tweeter.shutdown(); + } + + @Test + void shutdownOpenStream() throws MastodonException { + when(client.streaming()).thenReturn(streaming); + when(streaming.stream()).thenReturn(eventStream); + doNothing().when(logger).debug("shutdown()"); + doNothing().when(eventStream).close(); + doNothing().when(logger).info("Closed stream {}", eventStream); + + assertThat(tweeter.createRegisteredStream()).isSameAs(eventStream); + assertThatNoException().isThrownBy(tweeter::shutdown); + } + + @Test + void shutdownOpenStreamFails() throws MastodonException { + MastodonException problem = new MastodonException(null); + when(client.streaming()).thenReturn(streaming); + when(streaming.stream()).thenReturn(eventStream); + doNothing().when(logger).debug("shutdown()"); + doThrow(problem).when(eventStream).close(); + doNothing().when(logger).error("Error wile closing stream {}", eventStream, problem); + + assertThat(tweeter.createRegisteredStream()).isSameAs(eventStream); + assertThatNoException().isThrownBy(tweeter::shutdown); + } + + @Test + void createTweetStream() { + AccessToken accessToken = AccessToken.create(ACCESS_TOKEN_VALUE); + List> consumersOne = new ArrayList<>(); + List> consumersTwo = new ArrayList<>(); + EventStream eventStreamTwo = mock("eventStreamTwo"); + doNothing().when(logger).debug("createTweetStream({})", filterQuery); + doNothing().when(logger).error("Track names not supported: {}", List.of("OR")); + when(filterQuery.getTrack()).thenReturn(new String[]{"#vdz23", "OR", "@Devoxx", "@reinhapa", "#itWillBeFun"}); + when(client.streaming()).thenReturn(streaming); + when(streaming.stream()).thenReturn(eventStream, eventStreamTwo); + doAnswer(ctx -> consumersOne.add(ctx.getArgument(0))).when(eventStream).registerConsumer(any()); + doNothing().when(eventStream).changeSubscription(hashtag(true, accessToken, "vdz23")); + doNothing().when(eventStream).changeSubscription(hashtag(true, accessToken, "itWillBeFun")); + doAnswer(ctx -> consumersTwo.add(ctx.getArgument(0))).when(eventStreamTwo).registerConsumer(any()); + doNothing().when(eventStreamTwo).changeSubscription(stream(true, accessToken, "public")); + + assertThat(tweeter.createTweetStream(filterQuery)).isNotNull(); + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStreamTest.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStreamTest.java new file mode 100644 index 000000000..8fe33e3dd --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/StatusStreamTest.java @@ -0,0 +1,82 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mastodon4j.core.api.entities.Status; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.slf4j.Logger; +import org.tweetwallfx.tweet.api.Tweet; + +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createStatus; + +@MockitoSettings +class StatusStreamTest { + @Mock(name = "org.tweetwallfx.tweet.impl.mastodon4j.StatusStream") + Logger logger; + @Mock(name = "tweetConsumer") + Consumer tweetConsumer; + StatusStream stream; + + @BeforeEach + void prepare() { + stream = new StatusStream(); + } + + @AfterEach + void verifyMocks() { + verifyNoMoreInteractions(logger, tweetConsumer); + } + + @Test + void onTweet() { + doNothing().when(logger).debug("onTweet({})", tweetConsumer); + assertThatNoException().isThrownBy(() -> stream.onTweet(tweetConsumer)); + } + + @Test + void acceptNoConsumers() { + Status status = createStatus("4711", "gugus"); + doNothing().when(logger).debug("Notify status:\n{}", status); + assertThatNoException().isThrownBy(() -> stream.accept(status)); + } + + @Test + void accept() { + Status status = createStatus("4711", "gugus"); + doNothing().when(logger).debug("onTweet({})", tweetConsumer); + doNothing().when(logger).debug("Notify status:\n{}", status); + doNothing().when(tweetConsumer).accept(new MastodonStatus(status)); + stream.onTweet(tweetConsumer); + assertThatNoException().isThrownBy(() -> stream.accept(status)); + } +} diff --git a/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicateTest.java b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicateTest.java new file mode 100644 index 000000000..5d430ffd7 --- /dev/null +++ b/tweet-impl-mastodon4j/src/test/java/org/tweetwallfx/tweet/impl/mastodon4j/UserMentionPredicateTest.java @@ -0,0 +1,74 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 TweetWallFX + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.tweetwallfx.tweet.impl.mastodon4j; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mastodon4j.core.api.entities.Status; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.slf4j.Logger; + +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createMention; +import static org.tweetwallfx.tweet.impl.mastodon4j.MastodonEntities.createStatus; + +@MockitoSettings +public class UserMentionPredicateTest { + @Mock(name = "org.tweetwallfx.tweet.impl.mastodon4j.UserMentionPredicate") + Logger logger; + + UserMentionPredicate predicate; + + @BeforeEach + void prepare() { + predicate = new UserMentionPredicate(List.of("@reinhapa", "@devoxx")); + } + + @AfterEach + void verifyMocks() { + verifyNoMoreInteractions(logger); + } + + @Test + void test() { + final List mentions = List.of(createMention("12", "JohnDoe")); + Status statusOne = createStatus("1", "bli", mentions); + Set filterList = new TreeSet<>(String::compareToIgnoreCase); + filterList.addAll(List.of("devoxx","reinhapa")); + + doNothing().when(logger).debug("No matching users {} mentioned in {}", filterList, mentions); + assertThat(predicate.test(statusOne)).isFalse(); + + Status statusTwo = createStatus("2", "bla", List.of(createMention("42","REINHAPA"))); + assertThat(predicate.test(statusTwo)).isTrue(); + } +}