[SIS-xxx] changing the setup for donation tracking

This commit is contained in:
Sheldan
2025-11-29 14:21:48 +01:00
parent ea5a7509bc
commit c9b5258798
40 changed files with 452 additions and 293 deletions

View File

@@ -15,6 +15,11 @@
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>dev.sheldan.abstracto.scheduling</groupId>
<artifactId>scheduling-int</artifactId>
<version>${abstracto.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -28,6 +33,10 @@
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -9,8 +9,6 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static dev.sheldan.sissi.module.debra.config.DebraFeatureConfig.DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME;
@RestController
@RequestMapping(value = "/debra")
public class DebraDonationStatusController {
@@ -20,50 +18,38 @@ public class DebraDonationStatusController {
@GetMapping(value = "/latestDonations", produces = "application/json")
public DonationStats getLatestDonations() {
Long serverId = Long.parseLong(System.getenv(DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME));
DonationsResponse donationResponse = donationService.getSynchronizedCachedDonationAmount(serverId);
DonationsResponse donationResponse = donationService.getSynchronizedCachedDonationAmount();
List<DonationInfo> donations = donationService.getLatestDonations(donationResponse, Integer.MAX_VALUE)
.stream()
.map(DonationInfo::fromDonationItemModel)
.toList();
return DonationStats
.builder()
.totalAmount(donationResponse.getPage().getCollected())
.totalAmount(donationResponse.getCurrentDonationAmount())
.donations(donations)
.build();
}
@GetMapping(value = "/highestDonations", produces = "application/json")
public DonationStats getHighestDonations() {
Long serverId = Long.parseLong(System.getenv(DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME));
DonationsResponse donationResponse = donationService.getSynchronizedCachedDonationAmount(serverId);
DonationsResponse donationResponse = donationService.getSynchronizedCachedDonationAmount();
List<DonationInfo> donations = donationService.getHighestDonations(donationResponse, Integer.MAX_VALUE)
.stream()
.map(DonationInfo::fromDonationItemModel)
.toList();
return DonationStats
.builder()
.totalAmount(donationResponse.getPage().getCollected())
.totalAmount(donationResponse.getCurrentDonationAmount())
.donations(donations)
.build();
}
@GetMapping(value = "/campaignInfo", produces = "application/json")
public CampaignInfo getCampaignInfo() {
Long serverId = Long.parseLong(System.getenv(DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME));
DonationsResponse donationResponse = donationService.getSynchronizedCachedDonationAmount(serverId);
DonationsResponse donationResponse = donationService.getSynchronizedCachedDonationAmount();
Description pageObject = donationResponse.getPage();
return CampaignInfo
.builder()
.collected(pageObject.getCollected())
.collectedNet(pageObject.getCollectedNet())
.donationCount(donationResponse.getDonationCount())
.currency(pageObject.getCurrency())
.percent(pageObject.getPercent())
.displayName(pageObject.getDisplayName())
.slug(pageObject.getSlug())
.target(pageObject.getTarget())
.build();
}
}

View File

@@ -36,8 +36,8 @@ public class EndlessStreamController {
public EndlessStreamInfo getLatestDonations(@PathVariable("id") Long id) {
Long serverId = Long.parseLong(System.getenv(DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME));
EndlessStream endlessStream = endlessStreamManagementServiceBean.getEndlessStream(id);
DonationsResponse donationInfo = donationService.getSynchronizedCachedDonationAmount(serverId);
BigDecimal collectedAmount = donationInfo.getPage().getCollected();
DonationsResponse donationInfo = donationService.getSynchronizedCachedDonationAmount();
BigDecimal collectedAmount = donationInfo.getCurrentDonationAmount();
Long minuteRate = configService.getLongValueOrConfigDefault(DebraFeatureConfig.ENDLESS_STREAM_MINUTE_RATE, serverId);
Instant endDate = endlessStream.getStartTime().plus(collectedAmount.multiply(new BigDecimal(minuteRate)).toBigInteger().longValue(), ChronoUnit.MINUTES);
return EndlessStreamInfo

View File

@@ -6,9 +6,9 @@ import dev.sheldan.abstracto.core.command.config.CommandConfiguration;
import dev.sheldan.abstracto.core.command.config.HelpInfo;
import dev.sheldan.abstracto.core.command.config.Parameter;
import dev.sheldan.abstracto.core.command.execution.CommandContext;
import dev.sheldan.abstracto.core.command.execution.CommandParameterKey;
import dev.sheldan.abstracto.core.command.execution.CommandResult;
import dev.sheldan.abstracto.core.config.FeatureDefinition;
import dev.sheldan.abstracto.core.exception.AbstractoRunTimeException;
import dev.sheldan.abstracto.core.interaction.InteractionService;
import dev.sheldan.abstracto.core.interaction.slash.SlashCommandConfig;
import dev.sheldan.abstracto.core.interaction.slash.parameter.SlashCommandParameterService;
@@ -26,7 +26,6 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -70,11 +69,12 @@ public class Donations extends AbstractConditionableCommand {
Integer selectionValue = (Integer) parameters.get(1);
Integer top = null;
Integer latest = null;
switch (type) {
case "top": top = selectionValue; break;
default:
case "latest" :
latest = selectionValue; break;
if(type != null) {
DonationsTypeParameterKey typeKey = CommandParameterKey.getEnumFromKey(DonationsTypeParameterKey.class, type);
switch (typeKey) {
case LATEST -> latest = selectionValue;
case TOP -> top = selectionValue;
}
}
messageToSend = getDonationMessageToSend(commandContext.getGuild().getIdLong(), top, latest);
}
@@ -98,11 +98,10 @@ public class Donations extends AbstractConditionableCommand {
Integer top = null;
Integer latest = null;
if(selectionType != null) {
switch (selectionType) {
case "top": top = selectionValue; break;
default:
case "latest" :
latest = selectionValue; break;
DonationsTypeParameterKey typeKey = CommandParameterKey.getEnumFromKey(DonationsTypeParameterKey.class, selectionType);
switch (typeKey) {
case LATEST -> latest = selectionValue;
case TOP -> top = selectionValue;
}
}
@@ -113,7 +112,7 @@ public class Donations extends AbstractConditionableCommand {
private MessageToSend getDonationMessageToSend(Long serverId, Integer top, Integer latest) {
DonationsModel donationModel;
DonationsResponse donationResponse = donationService.fetchCurrentDonationAmount(serverId);
DonationsResponse donationResponse = donationService.fetchCurrentDonations();
donationModel = donationConverter.convertDonationResponse(donationResponse);
if(top != null) {
donationModel.setDonations(donationService.getHighestDonations(donationResponse, top));
@@ -146,7 +145,7 @@ public class Donations extends AbstractConditionableCommand {
.templated(true)
.name(SELECTION_PARAMETER)
.optional(true)
.type(String.class)
.type(DonationsTypeParameterKey.class)
.build();

View File

@@ -0,0 +1,13 @@
package dev.sheldan.sissi.module.debra.commands;
import dev.sheldan.abstracto.core.command.execution.CommandParameterKey;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum DonationsTypeParameterKey implements CommandParameterKey {
TOP("top"), LATEST("latest");
private String key;
}

View File

@@ -13,7 +13,6 @@ public class DebraFeatureConfig implements FeatureConfig {
public static final String DEBRA_DONATION_NOTIFICATION_DELAY_CONFIG_KEY = "debraDonationNotificationDelayMillis";
public static final String ENDLESS_STREAM_MINUTE_RATE = "endlessStreamMinuteRate";
public static final String DEBRA_DONATION_API_FETCH_SIZE_KEY = "debraDonationApiFetchSize";
public static final String DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME = "DEBRA_DONATION_NOTIFICATION_SERVER_ID";
@Override
public FeatureDefinition getFeature() {
@@ -27,6 +26,6 @@ public class DebraFeatureConfig implements FeatureConfig {
@Override
public List<String> getRequiredSystemConfigKeys() {
return Arrays.asList(DEBRA_DONATION_NOTIFICATION_DELAY_CONFIG_KEY, DEBRA_DONATION_API_FETCH_SIZE_KEY, ENDLESS_STREAM_MINUTE_RATE);
return Arrays.asList(DEBRA_DONATION_NOTIFICATION_DELAY_CONFIG_KEY, ENDLESS_STREAM_MINUTE_RATE);
}
}

View File

@@ -10,6 +10,5 @@ import org.springframework.context.annotation.Configuration;
@Setter
@ConfigurationProperties(prefix = "sissi.debra")
public class DebraProperties {
private String websocketURL;
private String donationAPIUrl;
private String donationPageUrl;
}

View File

@@ -1,6 +1,6 @@
package dev.sheldan.sissi.module.debra.converter;
import dev.sheldan.sissi.module.debra.model.api.Donation;
import dev.sheldan.sissi.module.debra.model.api.DonationDto;
import dev.sheldan.sissi.module.debra.model.api.DonationsResponse;
import dev.sheldan.sissi.module.debra.model.commands.DonationItemModel;
import dev.sheldan.sissi.module.debra.model.commands.DonationsModel;
@@ -9,20 +9,19 @@ import org.springframework.stereotype.Component;
@Component
public class DonationConverter {
public DonationItemModel convertDonation(Donation donation) {
public DonationItemModel convertDonation(DonationDto donation) {
return DonationItemModel
.builder()
.donationAmount(donation.getAmount())
.firstName(donation.getFirstname())
.lastName(donation.getLastname())
.anonymous(BooleanUtils.toBoolean(donation.getAnonym()))
.name(donation.getName())
.anonymous(BooleanUtils.toBoolean(donation.getAnonymous()))
.build();
}
public DonationsModel convertDonationResponse(DonationsResponse response) {
return DonationsModel
.builder()
.totalAmount(response.getPage().getCollected())
.totalAmount(response.getCurrentDonationAmount())
.build();
}
}

View File

@@ -0,0 +1,31 @@
package dev.sheldan.sissi.module.debra.job;
import dev.sheldan.sissi.module.debra.service.DonationService;
import lombok.extern.slf4j.Slf4j;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.PersistJobDataAfterExecution;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;
@Slf4j
@DisallowConcurrentExecution
@Component
@PersistJobDataAfterExecution
public class DonationFetchJob extends QuartzJobBean {
@Autowired
private DonationService donationService;
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
try {
log.info("Checking for new donations.");
donationService.checkForNewDonations();
} catch (Exception e) {
log.error("Failed to check for new donations.", e);
}
}
}

View File

@@ -1,96 +0,0 @@
package dev.sheldan.sissi.module.debra.listener;
import dev.sheldan.abstracto.core.listener.AsyncStartupListener;
import dev.sheldan.abstracto.core.service.ConfigService;
import dev.sheldan.sissi.module.debra.config.DebraProperties;
import dev.sheldan.sissi.module.debra.model.listener.DonationResponseModel;
import dev.sheldan.sissi.module.debra.service.DonationService;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.Nullable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static dev.sheldan.sissi.module.debra.config.DebraFeatureConfig.DEBRA_DONATION_NOTIFICATION_DELAY_CONFIG_KEY;
import static dev.sheldan.sissi.module.debra.config.DebraFeatureConfig.DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME;
@Component
@Slf4j
public class WebsocketListener extends WebSocketListener implements AsyncStartupListener {
@Autowired
private DonationService donationService;
@Autowired
private DebraProperties debraProperties;
@Autowired
private ConfigService configService;
private WebSocket webSocketObj;
private OkHttpClient clientObj;
@Override
public void onOpen(WebSocket webSocket, Response response) {
log.info("Connected to donation websocket.");
super.onOpen(webSocket, response);
}
@Override
public void onMessage(WebSocket webSocket, String text) {
CompletableFuture.runAsync(() -> {
log.info("Handling received message on websocket.");
try {
Long targetServerId = Long.parseLong(System.getenv(DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME));
Long delayMillis = configService.getLongValueOrConfigDefault(DEBRA_DONATION_NOTIFICATION_DELAY_CONFIG_KEY, targetServerId);
log.info("Waiting {} milli seconds to send notification.", delayMillis);
Thread.sleep(delayMillis);
log.info("Loading new donation amount and sending notification.");
DonationResponseModel donation = donationService.parseDonationFromMessage(text);
donationService.sendDonationNotification(donation).thenAccept(unused -> {
log.info("Successfully notified about donation.");
}).exceptionally(throwable -> {
log.error("Failed to notify about donation.", throwable);
return null;
});
} catch (Exception exception) {
log.error("Failed to handle websocket message.", exception);
}
});
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, @Nullable Response response) {
log.warn("Websocket connection failed...", t);
}
@Override
public void onClosing(WebSocket webSocket, int code, String reason) {
log.info("Closing websocket connection. It was closed with code {} and reason {}.", code, reason);
}
@Override
public void execute() {
if(clientObj != null) {
clientObj.connectionPool().evictAll();
clientObj.dispatcher().executorService().shutdownNow();
}
clientObj = new OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(true)
.build();
startConnection(clientObj);
clientObj.dispatcher().executorService().shutdown();
}
private void startConnection(OkHttpClient client) {
log.info("Starting websocket connection.");
Request request = new Request.Builder()
.url(debraProperties.getWebsocketURL())
.build();
this.webSocketObj = client.newWebSocket(request, this);
}
}

View File

@@ -1,28 +0,0 @@
package dev.sheldan.sissi.module.debra.model.api;
import com.google.gson.annotations.SerializedName;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@Builder
public class Description {
@SerializedName("collected")
private BigDecimal collected;
@SerializedName("target")
private BigDecimal target;
@SerializedName("currency")
private String currency;
@SerializedName("slug")
private String slug;
@SerializedName("displayname")
private String displayName;
@SerializedName("collectednet")
private BigDecimal collectedNet;
@SerializedName("percent")
private BigDecimal percent;
}

View File

@@ -1,26 +0,0 @@
package dev.sheldan.sissi.module.debra.model.api;
import com.google.gson.annotations.SerializedName;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@Builder
public class Donation {
@SerializedName("amount")
private BigDecimal amount;
@SerializedName("currency")
private String currency;
@SerializedName("text")
private String text;
@SerializedName("anonym")
private Integer anonym;
@SerializedName("firstname")
private String firstname;
@SerializedName("lastname")
private String lastname;
}

View File

@@ -0,0 +1,27 @@
package dev.sheldan.sissi.module.debra.model.api;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Getter
@Setter
@Builder
@ToString
public class DonationDto {
private BigDecimal amount;
private String currency;
private String text;
private Boolean anonymous;
private String name;
private LocalDate date;
public String stringRepresentation() {
return String.format("%s %s %s %s %s", name, amount, text, anonymous, date.format(DateTimeFormatter.ISO_DATE));
}
}

View File

@@ -11,7 +11,7 @@ import java.math.BigDecimal;
@Setter
@Builder
public class DonationInfo {
private String firstName;
private String name;
private BigDecimal donationAmount;
private Boolean anonymous;
@@ -20,7 +20,7 @@ public class DonationInfo {
.builder()
.donationAmount(donationItemModel.getDonationAmount())
.anonymous(donationItemModel.getAnonymous())
.firstName(donationItemModel.getFirstName())
.name(donationItemModel.getName())
.build();
}
}

View File

@@ -1,21 +1,18 @@
package dev.sheldan.sissi.module.debra.model.api;
import com.google.gson.annotations.SerializedName;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import java.math.BigInteger;
import java.math.BigDecimal;
import java.util.List;
@Getter
@Setter
@Builder
public class DonationsResponse {
@SerializedName("page")
private Description page;
@SerializedName("donation_count")
private BigInteger donationCount;
@SerializedName("donations")
private List<Donation> donations;
private BigDecimal currentDonationAmount;
private BigDecimal donationAmountGoal;
private int donationCount;
private List<DonationDto> donations;
}

View File

@@ -5,13 +5,14 @@ import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
@Getter
@Setter
@Builder
public class DonationItemModel {
private String firstName;
private String lastName;
private String name;
private LocalDate date;
private BigDecimal donationAmount;
private Boolean anonymous;
}

View File

@@ -0,0 +1,30 @@
package dev.sheldan.sissi.module.debra.model.database;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "donation")
@Getter
@Setter
@EqualsAndHashCode
public class Donation {
@Id
@Column(name = "id", nullable = false)
private String id;
// we cant be sure about duplicates
@Column(name = "count")
private Integer count;
@Column(name = "created")
private Instant created;
@Column(name = "updated")
private Instant updated;
}

View File

@@ -11,6 +11,7 @@ import java.math.BigDecimal;
@ToString
public class DonationResponseModel {
private String donatorName;
private Boolean anonymous;
private BigDecimal amount;
private String message;
}

View File

@@ -0,0 +1,9 @@
package dev.sheldan.sissi.module.debra.repository;
import dev.sheldan.sissi.module.debra.model.database.Donation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DonationRepository extends JpaRepository<Donation, String> {
}

View File

@@ -1,52 +1,52 @@
package dev.sheldan.sissi.module.debra.service;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import dev.sheldan.abstracto.core.exception.AbstractoRunTimeException;
import dev.sheldan.abstracto.core.interaction.ComponentPayloadService;
import dev.sheldan.abstracto.core.interaction.ComponentService;
import dev.sheldan.abstracto.core.models.database.AServer;
import dev.sheldan.abstracto.core.service.ChannelService;
import dev.sheldan.abstracto.core.service.ConfigService;
import dev.sheldan.abstracto.core.service.HashService;
import dev.sheldan.abstracto.core.service.PostTargetService;
import dev.sheldan.abstracto.core.service.management.ServerManagementService;
import dev.sheldan.abstracto.core.templating.model.MessageToSend;
import dev.sheldan.abstracto.core.templating.service.TemplateService;
import dev.sheldan.abstracto.core.utils.CompletableFutureList;
import dev.sheldan.abstracto.core.utils.FutureUtils;
import dev.sheldan.sissi.module.debra.exception.DonationAmountNotFoundException;
import dev.sheldan.sissi.module.debra.config.DebraPostTarget;
import dev.sheldan.sissi.module.debra.config.DebraProperties;
import dev.sheldan.sissi.module.debra.converter.DonationConverter;
import dev.sheldan.sissi.module.debra.model.api.Donation;
import dev.sheldan.sissi.module.debra.model.api.DonationDto;
import dev.sheldan.sissi.module.debra.model.api.DonationsResponse;
import dev.sheldan.sissi.module.debra.model.commands.DebraInfoButtonPayload;
import dev.sheldan.sissi.module.debra.model.commands.DebraInfoModel;
import dev.sheldan.sissi.module.debra.model.commands.DonationItemModel;
import dev.sheldan.sissi.module.debra.model.commands.DonationsModel;
import dev.sheldan.sissi.module.debra.model.database.Donation;
import dev.sheldan.sissi.module.debra.model.listener.DonationResponseModel;
import dev.sheldan.sissi.module.debra.model.listener.DonationNotificationModel;
import dev.sheldan.sissi.module.debra.service.management.DonationManagementServiceBean;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.lang3.tuple.Pair;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static dev.sheldan.sissi.module.debra.config.DebraFeatureConfig.DEBRA_DONATION_API_FETCH_SIZE_KEY;
import static dev.sheldan.sissi.module.debra.config.DebraFeatureConfig.DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME;
@Component
@@ -62,15 +62,9 @@ public class DonationService {
@Autowired
private TemplateService templateService;
@Autowired
private OkHttpClient okHttpClient;
@Autowired
private DonationConverter donationConverter;
@Autowired
private ConfigService configService;
@Autowired
private ChannelService channelService;
@@ -83,100 +77,209 @@ public class DonationService {
@Autowired
private ServerManagementService serverManagementService;
@Autowired
private DonationManagementServiceBean donationManagementServiceBean;
@Autowired
private HashService hashService;
@Autowired
private DonationService self;
private static final String DEBRA_DONATION_NOTIFICATION_TEMPLATE_KEY = "debra_donation_notification";
private static final Pattern MESSAGE_PATTERN = Pattern.compile("(.*) hat (\\d{1,9},\\d{2}) Euro gespendet!<br \\/>Vielen Dank!<br \\/>Nachricht:<br \\/>(.*)");
private static final String DEBRA_INFO_BUTTON_MESSAGE_TEMPLATE_KEY = "debraInfoButton";
public static final String DEBRA_INFO_BUTTON_ORIGIN = "DEBRA_INFO_BUTTON";
public DonationResponseModel parseDonationFromMessage(String message) {
Matcher matcher = MESSAGE_PATTERN.matcher(message);
if (matcher.find()) {
String donatorName = matcher.group(1);
String amountString = matcher.group(2);
BigDecimal amount = new BigDecimal(amountString.replace(',', '.'));
String donationMessage = Optional.ofNullable(matcher.group(3)).map(msg -> msg.replaceAll("(<br>)+", " ")).map(String::trim).orElse("");
return DonationResponseModel
.builder()
.message(donationMessage)
.donatorName(donatorName)
.amount(amount)
.build();
} else {
throw new IllegalArgumentException("String in wrong format");
}
}
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("d.M.y");
public List<DonationItemModel> getHighestDonations(DonationsResponse response, Integer maxCount) {
List<Donation> topDonations = response
return response
.getDonations()
.stream()
.sorted(Comparator.comparing(Donation::getAmount)
.sorted(Comparator.comparing(DonationDto::getAmount)
.reversed())
.collect(Collectors.toList());
return topDonations
.stream()
.limit(maxCount)
.map(donation -> donationConverter.convertDonation(donation))
.collect(Collectors.toList());
.toList();
}
public List<DonationItemModel> getLatestDonations(DonationsResponse response, Integer maxCount) {
return response
.getDonations()
.stream()
.sorted(Comparator.comparing(DonationDto::getDate).reversed())
.limit(maxCount)
.map(donation -> donationConverter.convertDonation(donation))
.collect(Collectors.toList());
}
public synchronized DonationsResponse getSynchronizedCachedDonationAmount(Long serverId) {
return self.getCachedDonationAmount(serverId);
public synchronized DonationsResponse getSynchronizedCachedDonationAmount() {
return self.getCachedDonationAmount();
}
@Cacheable(value = "donation-cache")
public synchronized DonationsResponse getCachedDonationAmount(Long serverId) {
return self.fetchCurrentDonationAmount(serverId);
public synchronized DonationsResponse getCachedDonationAmount() {
return self.fetchCurrentDonations();
}
public DonationsResponse fetchCurrentDonationAmount(Long serverId) {
public DonationsResponse fetchCurrentDonations() {
try {
Long fetchSize = configService.getLongValueOrConfigDefault(DEBRA_DONATION_API_FETCH_SIZE_KEY, serverId);
Request request = new Request.Builder()
.url(String.format(debraProperties.getDonationAPIUrl(), fetchSize))
.get()
.build();
Response response = okHttpClient.newCall(request).execute();
if(!response.isSuccessful()) {
log.error("Failed to retrieve donation response. Response had code {} with body {} and headers {}.",
response.code(), response.body().string(), response.headers());
throw new DonationAmountNotFoundException();
Document donationPage = Jsoup.connect(debraProperties.getDonationPageUrl()).get();
DecimalFormat decimalFormat = getDecimalFormat();
Element endValueElement = donationPage.getElementById("end-value");
String endValueString = endValueElement.text();
Elements currentValueElement = donationPage.getElementsByClass("current_amount").get(0).getElementsByClass("value");
String[] valueArray = currentValueElement.text().split(" ");
String currentValueString = valueArray[0];
String currency = valueArray[1];
BigDecimal currentValue = (BigDecimal) decimalFormat.parse(currentValueString);
BigDecimal endValue = (BigDecimal) decimalFormat.parse(endValueString);
Element list = donationPage.getElementsByClass("donor-list").first();
Elements donationElements = list.getElementsByClass("list-item");
List<DonationDto> donations = new ArrayList<>();
for (Element donationMainElement : donationElements.asList()) {
Elements nameElement = donationMainElement.getElementsByClass("donor-list-name");
Elements dateElement = donationMainElement.getElementsByClass("donor-list-date");
Elements amountElement = donationMainElement.getElementsByClass("donor-list-amount");
Elements textElement = donationMainElement.getElementsByClass("donor-list-amount-text");
LocalDate dateValue;
if (dateElement.hasText()) {
dateValue = LocalDate.parse(dateElement.text(), DATE_FORMAT);
} else {
dateValue = null;
}
BigDecimal amount;
if (amountElement.hasText()) {
String amountText = amountElement.text().split(" ")[0];
amount = (BigDecimal) decimalFormat.parse(amountText);
} else {
amount = null;
}
String additionalText = textElement.text();
String name = nameElement.text();
boolean anonymous = name.isBlank();
donations.add(DonationDto
.builder()
.anonymous(anonymous)
.name(nameElement.text())
.amount(amount)
.currency(currency)
.name(name)
.text(additionalText)
.date(dateValue)
.build());
}
Gson gson = getGson();
return gson.fromJson(response.body().string(), DonationsResponse.class);
return DonationsResponse
.builder()
.donations(donations)
.currentDonationAmount(currentValue)
.donationAmountGoal(endValue)
.donationCount(donations.size())
.build();
} catch (Exception exception) {
throw new AbstractoRunTimeException(exception);
}
}
private Gson getGson() {
return new GsonBuilder()
.registerTypeAdapter(BigDecimal.class, new BigDecimalGsonAdapter())
.create();
private DecimalFormat getDecimalFormat() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setGroupingSeparator('.');
symbols.setDecimalSeparator(',');
String pattern = "#,##0.0#";
DecimalFormat decimalFormat = new DecimalFormat(pattern, symbols);
decimalFormat.setParseBigDecimal(true);
return decimalFormat;
}
private DonationsModel getDonationInfoModel(Long serverId) {
return donationConverter.convertDonationResponse(fetchCurrentDonationAmount(serverId));
private DonationsModel getDonationInfoModel() {
return donationConverter.convertDonationResponse(fetchCurrentDonations());
}
public CompletableFuture<Void> sendDonationNotification(DonationResponseModel donation) throws IOException {
private String hashDonation(DonationDto donation) {
return hashService.sha256HashString(donation.stringRepresentation());
}
@Transactional
public void checkForNewDonations() {
List<Donation> allDonations = donationManagementServiceBean.getAllDonations();
Map<String, Integer> existingHashes = allDonations
.stream()
.collect(Collectors.toMap(Donation::getId, Donation::getCount));
DonationsResponse donationResponse = fetchCurrentDonations();
Map<String, Pair<Integer, DonationDto>> donationFromPageHashes = new HashMap<>();
donationResponse.getDonations().forEach(donationDto -> {
String thisHash = hashDonation(donationDto);
if(donationFromPageHashes.containsKey(thisHash)) {
donationFromPageHashes.put(thisHash, Pair.of(donationFromPageHashes.get(thisHash).getLeft() + 1, donationDto));
} else {
donationFromPageHashes.put(thisHash, Pair.of(1, donationDto));
}
});
Set<String> pageHashesToRemove = new HashSet<>();
donationFromPageHashes.entrySet().forEach(pageHash -> {
if(existingHashes.containsKey(pageHash.getKey())) {
Integer existingDonation = existingHashes.get(pageHash.getKey());
int amountDifference = pageHash.getValue().getKey() - existingDonation;
if(amountDifference == 0) {
pageHashesToRemove.add(pageHash.getKey()); // it matches 1:1, we know about all of them already
} if(amountDifference < 0) {
pageHashesToRemove.add(pageHash.getKey());
log.warn("We have more donations than on the page of hash {}:{}.", pageHash.getKey(), amountDifference);
} else {
pageHash.setValue(Pair.of(amountDifference, pageHash.getValue().getRight()));
}
}
});
pageHashesToRemove.forEach(donationFromPageHashes::remove);
if(donationFromPageHashes.isEmpty()) {
log.info("No new donations - ending search.");
return;
}
List<CompletableFuture<Void>> notificationFutures = new ArrayList<>();
donationFromPageHashes.values().forEach(donationInfo -> {
for (int i = 0; i < donationInfo.getLeft(); i++) {
DonationDto donationDto = donationInfo.getRight();
DonationResponseModel model = DonationResponseModel
.builder()
.message(donationDto.getText())
.donatorName(donationDto.getName())
.amount(donationDto.getAmount())
.anonymous(donationDto.getAnonymous())
.build();
notificationFutures.add(sendDonationNotification(model));
}
});
new CompletableFutureList<>(notificationFutures).getMainFuture().thenAccept(unused -> {
log.info("All {} notifications send.", notificationFutures.size());
}).exceptionally(throwable -> {
log.warn("Failed to send notifications about {} new donations.", notificationFutures.size(), throwable);
return null;
});
log.info("Creating/updating {} donation entries.", donationFromPageHashes.size());
allDonations.forEach(donation -> {
Set<String> donationsToRemoveBecauseUpdate = new HashSet<>();
donationFromPageHashes.forEach((key, value) -> {
// its assumed that donationFromPageHashes only contains donations that need to be created
if (donation.getId().equals(key)) {
donation.setCount(value.getLeft() + donation.getCount());
donationManagementServiceBean.updateDonation(donation);
donationsToRemoveBecauseUpdate.add(key);
}
});
donationsToRemoveBecauseUpdate.forEach(donationFromPageHashes::remove);
});
donationFromPageHashes.forEach((key, value) ->
donationManagementServiceBean.saveDonation(key, value.getLeft()));
}
public CompletableFuture<Void> sendDonationNotification(DonationResponseModel donation) {
Long targetServerId = Long.parseLong(System.getenv(DEBRA_DONATION_NOTIFICATION_SERVER_ID_ENV_NAME));
DonationsModel donationInfoModel = getDonationInfoModel(targetServerId);
DonationsModel donationInfoModel = getDonationInfoModel();
DonationNotificationModel model = DonationNotificationModel
.builder()
.donation(donation)

View File

@@ -0,0 +1,33 @@
package dev.sheldan.sissi.module.debra.service.management;
import dev.sheldan.sissi.module.debra.model.database.Donation;
import dev.sheldan.sissi.module.debra.repository.DonationRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class DonationManagementServiceBean {
@Autowired
private DonationRepository donationRepository;
public List<Donation> getAllDonations() {
return donationRepository.findAll();
}
public void updateDonation(Donation donation) {
donationRepository.save(donation);
}
public Donation saveDonation(String hash, Integer count) {
Donation donation = Donation
.builder()
.id(hash)
.count(count)
.build();
return donationRepository.save(donation);
}
}

View File

@@ -4,8 +4,7 @@ abstracto.featureFlags.debra.enabled=false
abstracto.postTargets.debraDonationNotification.name=debraDonationNotification
abstracto.postTargets.debraDonationNotification2.name=debraDonationNotification2
sissi.debra.websocketURL=ws://spenden.baba.fm:8765/
sissi.debra.donationAPIUrl=https://www.altruja.de/api/page/discord-schmetterlingsaktion-2024?details=1&num=%s&ort=0
sissi.debra.donationPageUrl=https://secure.sicherhelfen.org/campaigns/07a3baf6-5cdc-4300-854b-ea2b36b0b218/show
abstracto.systemConfigs.debraDonationNotificationDelayMillis.name=debraDonationNotificationDelayMillis
abstracto.systemConfigs.debraDonationNotificationDelayMillis.longValue=60000

View File

@@ -0,0 +1,7 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.26.xsd" >
<include file="seedData/data.xml" relativeToChangelogFile="true"/>
<include file="tables/tables.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>

View File

@@ -0,0 +1,6 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.26.xsd" >
<include file="donation_fetch_job.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>

View File

@@ -0,0 +1,15 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.26.xsd" >
<changeSet author="Sheldan" id="donation_fetch_job-insert">
<insert tableName="scheduler_job">
<column name="name" value="donationFetchJob"/>
<column name="group_name" value="debra"/>
<column name="clazz" value="dev.sheldan.sissi.module.debra.job.DonationFetchJob"/>
<column name="active" value="true"/>
<column name="cron_expression" value="0 */2 * * * ?"/>
<column name="recovery" value="false"/>
</insert>
</changeSet>
</databaseChangeLog>

View File

@@ -0,0 +1,28 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.26.xsd" >
<changeSet author="Sheldan" id="donation-table">
<createTable tableName="donation">
<column name="id" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="count" type="NUMERIC">
<constraints nullable="false"/>
</column>
<column name="created" type="TIMESTAMP WITHOUT TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="updated" type="TIMESTAMP WITHOUT TIME ZONE"/>
</createTable>
<sql>
DROP TRIGGER IF EXISTS donation_update_trigger ON donation;
CREATE TRIGGER donation_update_trigger BEFORE UPDATE ON donation FOR EACH ROW EXECUTE PROCEDURE update_trigger_procedure();
</sql>
<sql>
DROP TRIGGER IF EXISTS donation_insert_trigger ON donation;
CREATE TRIGGER donation_insert_trigger BEFORE INSERT ON donation FOR EACH ROW EXECUTE PROCEDURE insert_trigger_procedure();
</sql>
</changeSet>
</databaseChangeLog>

View File

@@ -0,0 +1,6 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.26.xsd" >
<include file="donation.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>

View File

@@ -5,4 +5,5 @@
<include file="1.3.6/collection.xml" relativeToChangelogFile="true"/>
<include file="1.4.21/collection.xml" relativeToChangelogFile="true"/>
<include file="1.4.29/collection.xml" relativeToChangelogFile="true"/>
<include file="1.5.16/collection.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>