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;
+ }
+}