Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
}
],
"fallbackChannelPattern": "java-news-and-changes",
"videoLinkPattern": "http(s)?://www\\.youtube.com.*",
"pollIntervalInMinutes": 10
},
"memberCountCategoryPattern": "Info",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,23 @@
public record RSSFeedsConfig(@JsonProperty(value = "feeds", required = true) List<RSSFeed> feeds,
@JsonProperty(value = "fallbackChannelPattern",
required = true) String fallbackChannelPattern,
@JsonProperty(value = "videoLinkPattern", required = true) String videoLinkPattern,
@JsonProperty(value = "pollIntervalInMinutes", required = true) int pollIntervalInMinutes) {

/**
* Constructs a new {@link RSSFeedsConfig}.
*
* @param feeds The list of RSS feeds to subscribe to.
* @param fallbackChannelPattern The pattern used to identify the fallback text channel to use.
* @param videoLinkPattern pattern determining if a link is a video. It is then posted in a way
* to support Discord video embeds.
* @param pollIntervalInMinutes The interval (in minutes) for polling the RSS feeds for updates.
* @throws NullPointerException if any of the parameters (feeds or fallbackChannelPattern) are
* null
*/
public RSSFeedsConfig {
Objects.requireNonNull(feeds);
Objects.requireNonNull(fallbackChannelPattern);
Objects.requireNonNull(videoLinkPattern);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHIssueState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.togetherjava.tjbot.features.CommandVisibility;
import org.togetherjava.tjbot.features.SlashCommandAdapter;
Expand All @@ -18,6 +19,7 @@
import java.util.List;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.function.ToIntFunction;
import java.util.regex.Matcher;
import java.util.stream.Stream;
Expand All @@ -41,11 +43,12 @@ public final class GitHubCommand extends SlashCommandAdapter {
};

private static final String TITLE_OPTION = "title";
private static final Logger logger = LoggerFactory.getLogger(GitHubCommand.class);

private final GitHubReference reference;

private Instant lastCacheUpdate;
private List<String> autocompleteGHIssueCache;
private Instant lastCacheUpdate = Instant.EPOCH;
private List<String> autocompleteGHIssueCache = List.of();

/**
* Constructs an instance of GitHubCommand.
Expand All @@ -66,7 +69,14 @@ public GitHubCommand(GitHubReference reference) {
getData().addOption(OptionType.STRING, TITLE_OPTION,
"Title of the issue you're looking for", true, true);

updateCache();
CompletableFuture.runAsync(() -> {
try {
updateCache();
} catch (Exception e) {
logger.error("Unknown error updating the GitHub cache", e);
}
});

}

@Override
Expand Down Expand Up @@ -111,7 +121,7 @@ public void onAutoComplete(CommandAutoCompleteInteractionEvent event) {
event.replyChoiceStrings(choices).queue();
}

if (lastCacheUpdate.isAfter(Instant.now().minus(CACHE_EXPIRES_AFTER))) {
if (isCacheExpired()) {
updateCache();
}
}
Expand All @@ -121,12 +131,20 @@ private ToIntFunction<String> suggestionScorer(String title) {
return s -> StringDistances.editDistance(title, s.replaceFirst("\\[#\\d+] ", ""));
}

private boolean isCacheExpired() {
Instant cacheExpiresAt = lastCacheUpdate.plus(CACHE_EXPIRES_AFTER);
return Instant.now().isAfter(cacheExpiresAt);
}

private void updateCache() {
logger.debug("GitHub Autocomplete cache update started");

autocompleteGHIssueCache = reference.getRepositories().stream().map(repo -> {
try {
return repo.getIssues(GHIssueState.ALL);
return repo.queryIssues().pageSize(1000).list().toList();
} catch (IOException ex) {
throw new UncheckedIOException(ex);
throw new UncheckedIOException("Error fetching issues from repo " + repo.getName(),
ex);
}
})
.flatMap(List::stream)
Expand All @@ -135,5 +153,8 @@ private void updateCache() {
.toList();

lastCacheUpdate = Instant.now();

logger.debug("GitHub autocomplete cache update completed successfully. Cached {} issues.",
autocompleteGHIssueCache.size());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import net.dv8tion.jda.api.entities.Message;

import java.util.Locale;
import java.util.Optional;
import java.util.Set;

Expand All @@ -10,7 +11,9 @@ record Attachment(String fileName) {
Set.of("jpg", "jpeg", "png", "gif", "webp", "tiff", "svg", "apng");

boolean isImage() {
return getFileExtension().map(IMAGE_EXTENSIONS::contains).orElse(false);
return getFileExtension().map(ext -> ext.toLowerCase(Locale.US))
.map(IMAGE_EXTENSIONS::contains)
.orElse(false);
}

private Optional<String> getFileExtension() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
* Listener that receives all sent messages from channels, checks them for scam and takes
Expand Down Expand Up @@ -247,15 +248,24 @@ private void reportScamMessage(MessageReceivedEvent event, String reportTitle,

User author = event.getAuthor();
String avatarOrDefaultUrl = author.getEffectiveAvatarUrl();

MessageEmbed embed =
new EmbedBuilder().setDescription(event.getMessage().getContentStripped())
.setTitle(reportTitle)
.setAuthor(author.getName(), null, avatarOrDefaultUrl)
.setTimestamp(event.getMessage().getTimeCreated())
.setColor(AMBIENT_COLOR)
.setFooter(author.getId())
.build();
String content = event.getMessage().getContentStripped();
List<Message.Attachment> attachments = event.getMessage().getAttachments();

if (!attachments.isEmpty()) {
String attachmentInfo = attachments.stream()
.map(Message.Attachment::getFileName)
.collect(Collectors.joining(", "));
content += "%s(The message has %d attachment%s: %s)".formatted(
content.isBlank() ? "" : "\n", attachments.size(),
attachments.size() > 1 ? "s " : "", attachmentInfo);
}
MessageEmbed embed = new EmbedBuilder().setDescription(content)
.setTitle(reportTitle)
.setAuthor(author.getName(), null, avatarOrDefaultUrl)
.setTimestamp(event.getMessage().getTimeCreated())
.setColor(AMBIENT_COLOR)
.setFooter(author.getId())
.build();

MessageCreateBuilder messageBuilder = new MessageCreateBuilder().setEmbeds(embed);
if (!confirmDialog.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
import com.apptasticsoftware.rssreader.RssReader;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.utils.cache.SnowflakeCacheView;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import org.apache.commons.text.StringEscapeUtils;
import org.jetbrains.annotations.Nullable;
import org.jooq.tools.StringUtils;
Expand Down Expand Up @@ -79,6 +79,7 @@ public final class RSSHandlerRoutine implements Routine {
private final RssReader rssReader;
private final RSSFeedsConfig config;
private final Predicate<String> fallbackChannelPattern;
private final Predicate<String> isVideoLink;
private final Map<RSSFeed, Predicate<String>> targetChannelPatterns;
private final int interval;
private final Database database;
Expand All @@ -95,6 +96,7 @@ public RSSHandlerRoutine(Config config, Database database) {
this.database = database;
this.fallbackChannelPattern =
Pattern.compile(this.config.fallbackChannelPattern()).asMatchPredicate();
isVideoLink = Pattern.compile(this.config.videoLinkPattern()).asMatchPredicate();
this.targetChannelPatterns = new HashMap<>();
this.config.feeds().forEach(feed -> {
if (feed.targetChannelPattern() != null) {
Expand Down Expand Up @@ -155,7 +157,7 @@ private void sendRSS(JDA jda, RSSFeed feedConfig) {
}
rssItems.reversed()
.stream()
.filter(shouldItemBePosted.get())
.filter(shouldItemBePosted.orElseThrow())
.forEachOrdered(item -> postItem(textChannels, item, feedConfig));
}

Expand Down Expand Up @@ -241,8 +243,8 @@ private Optional<ZonedDateTime> getLatestPostDateFromItems(List<Item> items,
* @param feedConfig the RSS feed configuration
*/
private void postItem(List<TextChannel> textChannels, Item rssItem, RSSFeed feedConfig) {
MessageEmbed embed = constructEmbedMessage(rssItem, feedConfig).build();
textChannels.forEach(channel -> channel.sendMessageEmbeds(List.of(embed)).queue());
MessageCreateData message = constructMessage(rssItem, feedConfig);
textChannels.forEach(channel -> channel.sendMessage(message).queue());
}

/**
Expand Down Expand Up @@ -346,13 +348,18 @@ private List<TextChannel> getTextChannelsFromFeed(JDA jda, RSSFeed feed) {
}

/**
* Provides the {@link EmbedBuilder} from an RSS item used for sending RSS posts.
* Provides the message from an RSS item used for sending RSS posts.
*
* @param item the RSS item to construct the embed message from
* @param feedConfig the configuration of the RSS feed
* @return the constructed {@link EmbedBuilder} containing information from the RSS item
* @return the constructed message containing information from the RSS item
*/
private static EmbedBuilder constructEmbedMessage(Item item, RSSFeed feedConfig) {
private MessageCreateData constructMessage(Item item, RSSFeed feedConfig) {
if (item.getLink().filter(isVideoLink).isPresent()) {
// Automatic video previews are created on normal messages, not on embeds
return MessageCreateData.fromContent(item.getLink().orElseThrow());
}

final EmbedBuilder embedBuilder = new EmbedBuilder();
String title = item.getTitle().orElse("No title");
String titleLink = item.getLink().orElse("");
Expand Down Expand Up @@ -381,7 +388,7 @@ private static EmbedBuilder constructEmbedMessage(Item item, RSSFeed feedConfig)
embedBuilder.setDescription("No description");
}

return embedBuilder;
return MessageCreateData.fromEmbeds(embedBuilder.build());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,23 @@ void ignoresHarmlessAttachments() {
assertFalse(isScamResult);
}

@Test
@DisplayName("Messages containing suspicious attachments are flagged even if extensions are upper-case (jpg vs JPG)")
void detectsSuspiciousAttachmentsRegardlessOfCase() {
// GIVEN an empty message containing suspicious attachments with mixed case for extensions
String content = "";
List<Message.Attachment> attachments =
List.of(createImageAttachmentMock("1.JPG"), createImageAttachmentMock("2.JPG"),
createImageAttachmentMock("3.jpg"), createImageAttachmentMock("4.jpg"));
Message message = createMessageMock(content, attachments);

// WHEN analyzing it
boolean isScamResult = scamDetector.isScam(message);

// THEN flags it as scam
assertTrue(isScamResult);
}

@Test
@DisplayName("Suspicious messages send by trusted users are not flagged")
void ignoreTrustedUser() {
Expand Down
2 changes: 1 addition & 1 deletion database/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ var sqliteVersion = "3.50.1.0"
dependencies {
implementation 'com.google.code.findbugs:jsr305:3.0.2'
implementation "org.xerial:sqlite-jdbc:${sqliteVersion}"
implementation 'org.flywaydb:flyway-core:11.11.0'
implementation 'org.flywaydb:flyway-core:11.13.0'
implementation "org.jooq:jooq:$jooqVersion"

implementation project(':utils')
Expand Down
Loading