diff --git a/.github/ci-config.properties b/.github/ci-config.properties new file mode 100644 index 0000000..f9fddc5 --- /dev/null +++ b/.github/ci-config.properties @@ -0,0 +1,17 @@ +# Placeholder config used only by CI (see .github/workflows/ci.yml). +# The real config-local.properties is gitignored and never committed. +# These values are dummies so the Maven build's copy-config step has a file to copy. + +## Discord Settings +discord.apiToken=ci-placeholder +discord.guildID=0 +discord.channelID=0 + +## Steam Settings +steam.api=ci-placeholder + +## Leetify API +leetify.apiToken=ci-placeholder + +## Carthage settings +carthage.url=http://localhost/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7db739f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + # config-local.properties is gitignored and holds secrets, so it is not in + # the repo. The build's copy-config step only needs the file to exist, so a + # placeholder is enough to compile and run the tests in CI. + - name: Generate placeholder config + run: cp .github/ci-config.properties src/main/resources/config-local.properties + + - name: Run tests + run: mvn -B -ntp -P local test + + build: + name: Build package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Generate placeholder config + run: cp .github/ci-config.properties src/main/resources/config-local.properties + + - name: Build shaded jar + run: mvn -B -ntp -P local -DskipTests package + + - name: Upload jar + uses: actions/upload-artifact@v4 + with: + name: CounterStrikeBot-jar + path: target/CounterStrikeBot-*.jar + if-no-files-found: error diff --git a/pom.xml b/pom.xml index 64d5f5a..1f1c642 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ ch.yoinc CounterStrikeBot - 3.6 + 3.7 CounterStrikeBot https://github.com/yoinc-development/CounterStrikeBot @@ -67,7 +67,8 @@ org.mockito mockito-core - 5.12.0 + 5.23.0 + test net.kronos diff --git a/src/main/java/ch/yoinc/http/LeetifyConnection.java b/src/main/java/ch/yoinc/http/LeetifyConnection.java index 4120103..ba84869 100644 --- a/src/main/java/ch/yoinc/http/LeetifyConnection.java +++ b/src/main/java/ch/yoinc/http/LeetifyConnection.java @@ -88,7 +88,9 @@ public List getPlayerMatchHistory(String steam64ID, String return gson.fromJson(response.body(), new TypeToken>() { }.getType()); } - System.out.println("[CSBot - LeetifyConnection - " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy - HH:mm:ss")) + "] getPlayerMatchHistory for " + parameter + " returned status " + response.statusCode() + ", body: " + response.body()); + if(response.statusCode() != 404) { + System.out.println("[CSBot - LeetifyConnection - " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy - HH:mm:ss")) + "] getPlayerMatchHistory for " + parameter + " returned status " + response.statusCode() + ", body: " + response.body()); + } } catch (IOException | InterruptedException ex) { System.out.println("[CSBot - LeetifyConnection - " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy - HH:mm:ss")) + "] IOException / InterruptedException thrown: " + ex.getMessage()); } diff --git a/src/main/java/ch/yoinc/tasks/LeetifyTask.java b/src/main/java/ch/yoinc/tasks/LeetifyTask.java index 5980a26..51cb963 100644 --- a/src/main/java/ch/yoinc/tasks/LeetifyTask.java +++ b/src/main/java/ch/yoinc/tasks/LeetifyTask.java @@ -12,6 +12,7 @@ import java.awt.*; import java.util.*; import java.util.List; +import java.util.Locale; public class LeetifyTask implements ScheduledTask { @@ -20,15 +21,13 @@ public class LeetifyTask implements ScheduledTask { @Override public void execute(JDA jda, Properties properties) { - dataService = new DataService(properties); + if (dataService == null) { + dataService = new DataService(properties); + } + if (leetifyConnection == null) { + leetifyConnection = new LeetifyConnection(properties); + } dataService.setBotID(jda.getSelfUser().getId()); - leetifyConnection = new LeetifyConnection(properties); - - DiscordService discordService = new DiscordService(); - - String DEFAULT_LEETIFY_URL = "https://leetify.com/app/match-details/%s/overview"; - String DEFAULT_MAP_LOGO_URL = "https://raw.githubusercontent.com/MurkyYT/cs2-map-icons/main/images/%s.png"; - String DEFAULT_MAP_URL = "https://raw.githubusercontent.com/MurkyYT/cs2-map-icons/main/images/thumbs/%s_1_png.png"; List internalUsers = dataService.getAllSteamUsers(); @@ -41,137 +40,83 @@ public void execute(JDA jda, Properties properties) { for (String matchID : newlyPlayedMatches.keySet()) { List matches = newlyPlayedMatches.get(matchID); + EmbedBuilder matchEmbed = new EmbedBuilder(); + LeetifyMatchResponse match = matches.getFirst(); + boolean hasWon = match.stats.getFirst().rounds_won >= match.stats.getFirst().rounds_lost; //tie is a victory, change my mind if (matches.size() > 1) { - LeetifyMatchResponse match = matches.getFirst(); - String title = ""; - Color color = Color.BLUE; - String imageUrl = ""; - String thumbnailUrl = ""; - String footer = ""; - List playerNames = new ArrayList<>(); for (LeetifyMatchResponse playerMatch : matches) { playerNames.add(playerMatch.stats.getFirst().name); } String players = String.join(", ", playerNames); - String description = ""; - - switch (match.data_source) { - case "faceit": - title = "New Faceit Match"; - color = Color.ORANGE; - description = players + " played a new Faceit match together."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - case "wingman": - case "matchmaking_wingman": - title = "New Wingman Match"; - color = Color.GREEN; - description = players + " played a new Wingman match together."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - case "premier": - title = "New Premier Match"; - color = Color.YELLOW; - description = players + " played a new Premier match together."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - case "matchmaking": - title = "New Competitive Match"; - color = Color.RED; - description = players + " played a new Competitive match together."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - } - EmbedBuilder updateMessage = discordService.createEmbedBuilder(title, description, imageUrl, footer); - updateMessage - .setTitle(title, DEFAULT_LEETIFY_URL.replace("%s", matchID)) - .setColor(color) - .setThumbnail(thumbnailUrl); + matchEmbed = switch (match.data_source) { + case "faceit" -> returnFilledEmbed("New Faceit Match", + Color.ORANGE, players + " played a Faceit match together and " + ((hasWon) ? "**won**." : "**lost**."), + match.map_name, matchID, "Finished at " + match.finished_at); + case "matchmaking_wingman" -> returnFilledEmbed("New Wingman Match", + Color.GREEN, players + " played a Wingman match together and " + ((hasWon) ? "**won**." : "**lost**."), + match.map_name, matchID, "Finished at " + match.finished_at); + case "matchmaking" -> returnFilledEmbed("New Competitive Match", + Color.YELLOW, players + " played a Competitive match together and " + ((hasWon) ? "**won**." : "**lost**."), + match.map_name, matchID, "Finished at " + match.finished_at); + default -> matchEmbed; + }; for (LeetifyMatchResponse playerMatch : matches) { LeetifyPlayerStatsResponse stats = playerMatch.stats.getFirst(); - updateMessage.addField( + matchEmbed.addField( stats.name, "Kills: " + stats.total_kills + "\nDeaths: " + stats.total_deaths + "\nADR: " + stats.dpr + - "\nRating: " + stats.leetify_rating + - "\nCT Rating: " + stats.ct_leetify_rating + - "\nT Rating: " + stats.t_leetify_rating, + "\nRating: " + formatRating(stats.leetify_rating) + + "\nCT Rating: " + formatRating(stats.ct_leetify_rating) + + "\nT Rating: " + formatRating(stats.t_leetify_rating), true ); } - Objects.requireNonNull(jda.getTextChannelById(properties.getProperty("discord.channelID"))).sendMessageEmbeds(updateMessage.build()).queue(); } else { - LeetifyMatchResponse match = matches.getFirst(); - String title = ""; - Color color = Color.BLUE; - String description = ""; - String imageUrl = ""; - String thumbnailUrl = ""; - String footer = ""; - - switch (match.data_source) { - case "faceit": - title = "New Faceit Match"; - color = Color.ORANGE; - description = match.stats.getFirst().name + " played a new Faceit match."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - case "wingman": - case "matchmaking_wingman": - title = "New Wingman Match"; - color = Color.GREEN; - description = match.stats.getFirst().name + " played a new Wingman match."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - case "premier": - title = "New Premier Match"; - color = Color.YELLOW; - description = match.stats.getFirst().name + " played a new Premier match."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - case "matchmaking": - title = "New Competitive Match"; - color = Color.RED; - description = match.stats.getFirst().name + " played a new Competitive match."; - imageUrl = DEFAULT_MAP_URL.replace("%s", match.map_name); - thumbnailUrl = DEFAULT_MAP_LOGO_URL.replace("%s", match.map_name); - footer = "Finished at " + match.finished_at; - break; - } - EmbedBuilder updateMessage = discordService.createEmbedBuilder(title, description, imageUrl, footer); - updateMessage - .setTitle(title, DEFAULT_LEETIFY_URL.replace("%s", matchID)) - .setColor(color) - .setThumbnail(thumbnailUrl) + matchEmbed = switch (match.data_source) { + case "faceit" -> returnFilledEmbed("New Faceit Match", Color.ORANGE, + match.stats.getFirst().name + " played a Faceit match and " + ((hasWon) ? "**won**." : "**lost**."), + match.map_name, matchID, "Finished at " + match.finished_at); + case "matchmaking_wingman" -> returnFilledEmbed("New Wingman Match", Color.GREEN, + match.stats.getFirst().name + " played a Wingman match and " + ((hasWon) ? "**won**." : "**lost**."), + match.map_name, matchID, "Finished at " + match.finished_at); + case "matchmaking" -> returnFilledEmbed("New Competitive Match", Color.YELLOW, + match.stats.getFirst().name + " played a Competitive match and " + ((hasWon) ? "**won**." : "**lost**."), + match.map_name, matchID, "Finished at " + match.finished_at); + default -> matchEmbed; + }; + matchEmbed .addField("Kills", Integer.toString(match.stats.getFirst().total_kills), true) .addField("Deaths", Integer.toString(match.stats.getFirst().total_deaths), true) .addField("ADR", Double.toString(match.stats.getFirst().dpr), true) - .addField("Rating", Double.toString(match.stats.getFirst().leetify_rating), true) - .addField("CT Rating", Double.toString(match.stats.getFirst().ct_leetify_rating), true) - .addField("T Rating", Double.toString(match.stats.getFirst().t_leetify_rating), true); - Objects.requireNonNull(jda.getTextChannelById(properties.getProperty("discord.channelID"))).sendMessageEmbeds(updateMessage.build()).queue(); + .addField("Rating", formatRating(match.stats.getFirst().leetify_rating), true) + .addField("CT Rating", formatRating(match.stats.getFirst().ct_leetify_rating), true) + .addField("T Rating", formatRating(match.stats.getFirst().t_leetify_rating), true); } + Objects.requireNonNull(jda.getTextChannelById(properties.getProperty("discord.channelID"))).sendMessageEmbeds(matchEmbed.build()).queue(); } } + private EmbedBuilder returnFilledEmbed(String title, Color color, String description, String map_name, String matchID, String footer) { + DiscordService discordService = new DiscordService(); + + String DEFAULT_LEETIFY_URL = "https://leetify.com/app/match-details/%s/overview"; + String DEFAULT_MAP_LOGO_URL = "https://raw.githubusercontent.com/MurkyYT/cs2-map-icons/main/images/%s.png"; + String DEFAULT_MAP_URL = "https://raw.githubusercontent.com/MurkyYT/cs2-map-icons/main/images/thumbs/%s_1_png.png"; + + EmbedBuilder returnEmbed = discordService.createEmbedBuilder(title, description, DEFAULT_MAP_URL.replace("%s", map_name), footer); + returnEmbed + .setTitle(title, DEFAULT_LEETIFY_URL.replace("%s", matchID)) + .setColor(color) + .setThumbnail(DEFAULT_MAP_LOGO_URL.replace("%s", map_name)); + + return returnEmbed; + } + private HashMap> setNewlyPlayedMatches(List steamInternalUsers) { HashMap> results = new HashMap<>(); @@ -204,4 +149,11 @@ private HashMap> setNewlyPlayedMatches(List> results = invokeSetNewlyPlayedMatches(List.of(user(1, "STEAM1"))); + + assertTrue(results.isEmpty()); + verify(dataService, never()).insertAndGetNewMatches(any(), any()); + assertTrue(Thread.interrupted(), "interrupt flag should have been restored by the method"); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void setNewlyPlayedMatches_onlyIncludesMatchesReturnedAsNew() throws Exception { + DataService dataService = mock(DataService.class); + LeetifyConnection leetifyConnection = mock(LeetifyConnection.class); + + LeetifyMatchResponse oldMatch = match("old-match", "Alice"); + LeetifyMatchResponse newMatch = match("new-match", "Alice"); + List history = List.of(oldMatch, newMatch); + when(leetifyConnection.getPlayerMatchHistory(eq("STEAM1"), isNull())).thenReturn(history); + when(dataService.insertAndGetNewMatches(history, 1)).thenReturn(List.of("new-match")); + injectDependencies(dataService, leetifyConnection); + + Thread.currentThread().interrupt(); + + HashMap> results = invokeSetNewlyPlayedMatches(List.of(user(1, "STEAM1"))); + + assertEquals(1, results.size()); + assertFalse(results.containsKey("old-match")); + assertEquals(List.of(newMatch), results.get("new-match")); + Thread.interrupted(); + } + + @Test + @Timeout(value = 12, unit = TimeUnit.SECONDS) + void setNewlyPlayedMatches_groupsMatchesFromDifferentUsersUnderSameMatchId() throws Exception { + DataService dataService = mock(DataService.class); + LeetifyConnection leetifyConnection = mock(LeetifyConnection.class); + + LeetifyMatchResponse aliceMatch = match("shared-match", "Alice"); + LeetifyMatchResponse bobMatch = match("shared-match", "Bob"); + List aliceHistory = List.of(aliceMatch); + List bobHistory = List.of(bobMatch); + + when(leetifyConnection.getPlayerMatchHistory(eq("STEAM1"), isNull())).thenReturn(aliceHistory); + when(leetifyConnection.getPlayerMatchHistory(eq("STEAM2"), isNull())).thenReturn(bobHistory); + when(dataService.insertAndGetNewMatches(aliceHistory, 1)).thenReturn(List.of("shared-match")); + when(dataService.insertAndGetNewMatches(bobHistory, 2)).thenReturn(List.of("shared-match")); + injectDependencies(dataService, leetifyConnection); + + // the production loop sleeps 10s (real) between users, so the first sleep has to be + // waited out for the second user to be processed at all; once it's back we interrupt + // the worker to skip its trailing sleep instead of waiting a second 10s + AtomicReference>> resultRef = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + resultRef.set(invokeSetNewlyPlayedMatches(List.of(user(1, "STEAM1"), user(2, "STEAM2")))); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + worker.start(); + worker.join(10_500); + if (worker.isAlive()) { + worker.interrupt(); + worker.join(2_000); + } + + HashMap> results = resultRef.get(); + assertNotNull(results, "worker did not finish in time"); + assertEquals(1, results.size()); + assertEquals(List.of(aliceMatch, bobMatch), results.get("shared-match")); + } + + // --------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------- + + private MessageEmbed invokeReturnFilledEmbed(String title, Color color, String description, + String mapName, String matchId, String footer) throws Exception { + Method method = LeetifyTask.class.getDeclaredMethod("returnFilledEmbed", + String.class, Color.class, String.class, String.class, String.class, String.class); + method.setAccessible(true); + EmbedBuilder builder = (EmbedBuilder) method.invoke(task, title, color, description, mapName, matchId, footer); + return builder.build(); + } + + private String invokeFormatRating(Double rating) throws Exception { + Method method = LeetifyTask.class.getDeclaredMethod("formatRating", Double.class); + method.setAccessible(true); + return (String) method.invoke(null, rating); + } + + @SuppressWarnings("unchecked") + private HashMap> invokeSetNewlyPlayedMatches(List users) { + try { + Method method = LeetifyTask.class.getDeclaredMethod("setNewlyPlayedMatches", List.class); + method.setAccessible(true); + return (HashMap>) method.invoke(task, users); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void injectDependencies(DataService dataService, LeetifyConnection leetifyConnection) throws Exception { + setPrivateField("dataService", dataService); + setPrivateField("leetifyConnection", leetifyConnection); + } + + private void setPrivateField(String name, Object value) throws Exception { + Field field = LeetifyTask.class.getDeclaredField(name); + field.setAccessible(true); + field.set(task, value); + } + + private static InternalUser user(int id, String steamId) { + InternalUser user = new InternalUser(); + user.userID = id; + user.steamID = steamId; + return user; + } + + private static LeetifyMatchResponse match(String matchId, String playerName) { + LeetifyMatchResponse match = new LeetifyMatchResponse(); + match.id = matchId; + match.data_source = "matchmaking"; + match.map_name = "de_dust2"; + match.finished_at = Instant.now(); + LeetifyPlayerStatsResponse stats = new LeetifyPlayerStatsResponse(); + stats.name = playerName; + match.stats = List.of(stats); + return match; + } +}