Skip to content

Commit 9a63a3e

Browse files
committed
Cleaned up a lot of stuff, prepared metric aggregation.
1 parent 5ab5db9 commit 9a63a3e

24 files changed

+445
-19
lines changed

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ Discord bot for recording and analyzing user engagement, retention, and more.
55

66
Use this URL to add the bot to your server:
77

8-
https://discord.com/api/oauth2/authorize?client_id=817842720848347137&permissions=650304&redirect_uri=https%3A%2F%2Fjavadiscord.net%2F&response_type=code&scope=bot%20messages.read
8+
https://discord.com/api/oauth2/authorize?client_id=817842720848347137&permissions=650432&redirect_uri=https%3A%2F%2Fjavadiscord.net%2F&scope=bot
99

1010
### Configuration
11-
Currently, the application is only configured for the `development` profile. Please therefore use only `development` as the active profile when running the application.
11+
The application is configured for both a `development` and `production` spring profile.
1212

1313
For security reasons, the following properties must be declared as environment variables available to this program at runtime:
1414

1515
| Environment Variable | Description |
1616
| ---------------------- | ------------------------------------------------------------ |
1717
| `INSIGHTS_BOT_DB_URL` | The JDBC URL used to access the data source, usually of the form `jdbc:<db_type>://<ip>:<port>/<db_name>`. |
1818
| `INSIGHTS_BOT_DB_USER` | The user with which the bot will access the data source. |
19-
| `INSIGHTS_BOT_DB_PASS` | The password for the above user. |
19+
| `INSIGHTS_BOT_DB_PASS` | The password for the above user. |
20+
| `INSIGHTS_BOT_TOKEN` | The Discord Bot token to use for the bot. |

src/main/java/net/javadiscord/BotInitializer.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77
import lombok.extern.slf4j.Slf4j;
88
import net.javadiscord.command.CommandRegistry;
99
import net.javadiscord.command.HelpCommand;
10+
import net.javadiscord.command.analytics.CustomQueryCommand;
1011
import net.javadiscord.command.analytics.JoinCountCommand;
1112
import net.javadiscord.command.analytics.MessageCountCommand;
1213
import net.javadiscord.data.GuildEventRecorderService;
1314
import org.springframework.boot.CommandLineRunner;
1415
import org.springframework.stereotype.Service;
1516

1617
import java.time.Duration;
18+
import java.util.Arrays;
19+
import java.util.HashSet;
20+
import java.util.Set;
1721
import java.util.stream.Collectors;
1822

1923
/**
@@ -24,19 +28,27 @@
2428
@RequiredArgsConstructor
2529
@Slf4j
2630
public class BotInitializer implements CommandLineRunner {
31+
public static final Set<Long> ADMIN_IDS = new HashSet<>();
32+
2733
private final GuildEventRecorderService recorderService;
2834
private final CommandRegistry commandRegistry;
2935

3036
// Autowired commands (which require persistence components)
3137
private final MessageCountCommand messageCountCommand;
3238
private final JoinCountCommand joinCountCommand;
33-
//private final CustomQueryCommand customQueryCommand;
39+
private final CustomQueryCommand customQueryCommand;
3440

3541
@Override
3642
public void run(String... args) {
37-
if (args.length < 1 || args[0].isEmpty()) throw new IllegalArgumentException("Missing client token argument.");
43+
String token = System.getenv("INSIGHTS_BOT_TOKEN");
44+
if (token == null || token.trim().isEmpty()) throw new IllegalArgumentException("Missing client token argument.");
45+
ADMIN_IDS.addAll(Arrays.stream(System.getenv("INSIGHTS_BOT_ADMINS").split("\\s*,\\s*")).map(Long::parseLong).collect(Collectors.toSet()));
46+
if (!ADMIN_IDS.isEmpty()) {
47+
log.info("Started with admin ids: {}", ADMIN_IDS.toString());
48+
}
3849
this.initializeCommands();
39-
this.initializeBot(args[0]);
50+
log.info("Initialized commands.");
51+
this.initializeBot(token);
4052
}
4153

4254
/**
@@ -46,7 +58,7 @@ private void initializeCommands() {
4658
this.commandRegistry.register("help", new HelpCommand());
4759
this.commandRegistry.register("messageCount", this.messageCountCommand);
4860
this.commandRegistry.register("joinCount", this.joinCountCommand);
49-
//this.commandRegistry.register("customQuery", this.customQueryCommand);
61+
this.commandRegistry.register("customQuery", this.customQueryCommand);
5062
}
5163

5264
/**

src/main/java/net/javadiscord/command/Command.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import discord4j.core.event.domain.message.MessageCreateEvent;
44
import org.reactivestreams.Publisher;
5+
import org.springframework.lang.Nullable;
6+
7+
import java.util.Set;
58

69
/**
710
* Commands must simply declare the logic for the {@link Command#handle} method,
@@ -16,4 +19,11 @@ public interface Command {
1619
* handled successfully.
1720
*/
1821
Publisher<?> handle(MessageCreateEvent event, String[] args);
22+
23+
/**
24+
* @return A list of ids of users that are allowed to use the command.
25+
*/
26+
default @Nullable Set<Long> getWhitelistedUserIds() {
27+
return null;
28+
}
1929
}

src/main/java/net/javadiscord/command/CommandHandler.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package net.javadiscord.command;
22

33
import discord4j.core.event.domain.message.MessageCreateEvent;
4+
import discord4j.core.object.entity.User;
45
import lombok.RequiredArgsConstructor;
56
import org.reactivestreams.Publisher;
67
import org.springframework.stereotype.Component;
78
import reactor.core.publisher.Mono;
89

10+
import java.util.Arrays;
11+
import java.util.Optional;
12+
913
/**
1014
* Handles the process of preparing the contents of a command message for use
1115
* with {@link Command}s, by splitting the content by whitespace and extracting
@@ -28,9 +32,7 @@ public Publisher<?> handle(MessageCreateEvent event) {
2832
if (words.length == 0) {
2933
return Mono.empty();
3034
}
31-
String[] args = new String[words.length - 1];
32-
System.arraycopy(words, 1, args, 0, words.length - 1);
33-
return this.handle(event, args);
35+
return this.handle(event, Arrays.copyOfRange(words, 1, words.length));
3436
}
3537

3638
/**
@@ -43,9 +45,14 @@ public Publisher<?> handle(MessageCreateEvent event) {
4345
public Publisher<?> handle(MessageCreateEvent event, String[] words) {
4446
if (words.length > 0) {
4547
String keyword = words[0].trim().toLowerCase();
46-
String[] args = new String[words.length - 1];
47-
System.arraycopy(words, 1, args, 0, words.length - 1);
48-
return this.commandRegistry.get(keyword).orElse(unknownCommand).handle(event, args);
48+
Command cmd = this.commandRegistry.get(keyword).orElse(unknownCommand);
49+
if (cmd.getWhitelistedUserIds() != null && !cmd.getWhitelistedUserIds().isEmpty()) {
50+
Optional<User> optionalUser = event.getMessage().getAuthor();
51+
if (!optionalUser.isPresent() || !cmd.getWhitelistedUserIds().contains(optionalUser.get().getId().asLong())) {
52+
return event.getMessage().getChannel().flatMap(c -> c.createMessage("Command not permitted."));
53+
}
54+
}
55+
return cmd.handle(event, Arrays.copyOfRange(words, 1, words.length));
4956
}
5057
return Mono.empty();
5158
}

src/main/java/net/javadiscord/command/analytics/CustomQueryCommand.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import discord4j.core.event.domain.message.MessageCreateEvent;
44
import lombok.RequiredArgsConstructor;
55
import lombok.extern.slf4j.Slf4j;
6+
import net.javadiscord.BotInitializer;
67
import net.javadiscord.command.Command;
78
import org.reactivestreams.Publisher;
89
import org.springframework.jdbc.core.JdbcTemplate;
910
import org.springframework.stereotype.Component;
1011

1112
import java.util.Arrays;
13+
import java.util.Set;
1214

1315
@Component
1416
@RequiredArgsConstructor
@@ -49,4 +51,9 @@ public Publisher<?> handle(MessageCreateEvent event, String[] args) {
4951
String finalMsg = msg;
5052
return event.getMessage().getChannel().flatMap(c -> c.createMessage(finalMsg));
5153
}
54+
55+
@Override
56+
public Set<Long> getWhitelistedUserIds() {
57+
return BotInitializer.ADMIN_IDS;
58+
}
5259
}

src/main/java/net/javadiscord/data/GuildEventRecorderService.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package net.javadiscord.data;
22

33
import discord4j.core.event.ReactiveEventAdapter;
4+
import discord4j.core.event.domain.guild.BanEvent;
45
import discord4j.core.event.domain.guild.MemberJoinEvent;
56
import discord4j.core.event.domain.guild.MemberLeaveEvent;
7+
import discord4j.core.event.domain.guild.UnbanEvent;
68
import discord4j.core.event.domain.message.*;
79
import lombok.RequiredArgsConstructor;
810
import lombok.extern.slf4j.Slf4j;
@@ -28,6 +30,7 @@ public class GuildEventRecorderService extends ReactiveEventAdapter {
2830
private final GuildEventRepository guildEventRepository;
2931
private final CommandHandler commandHandler;
3032

33+
// ---- MESSAGE EVENTS ----
3134
@Override
3235
public Publisher<?> onMessageCreate(MessageCreateEvent event) {
3336
if (event.getGuildId().isPresent() && event.getMember().isPresent()) {
@@ -72,6 +75,7 @@ public Publisher<?> onReactionRemove(ReactionRemoveEvent event) {
7275
return Mono.empty();
7376
}
7477

78+
// ---- MEMBERSHIP EVENTS
7579
@Override
7680
public Publisher<?> onMemberJoin(MemberJoinEvent event) {
7781
return Mono.just(this.guildEventRepository.save(new MembershipEvent(event)));
@@ -81,4 +85,14 @@ public Publisher<?> onMemberJoin(MemberJoinEvent event) {
8185
public Publisher<?> onMemberLeave(MemberLeaveEvent event) {
8286
return Mono.just(this.guildEventRepository.save(new MembershipEvent(event)));
8387
}
88+
89+
@Override
90+
public Publisher<?> onBan(BanEvent event) {
91+
return Mono.just(this.guildEventRepository.save(new MembershipEvent(event)));
92+
}
93+
94+
@Override
95+
public Publisher<?> onUnban(UnbanEvent event) {
96+
return Mono.just(this.guildEventRepository.save(new MembershipEvent(event)));
97+
}
8498
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package net.javadiscord.data.aggregation;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import net.javadiscord.data.dao.MessagesMetricRepository;
6+
import net.javadiscord.util.SqlHelper;
7+
import org.springframework.jdbc.core.JdbcTemplate;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
import java.sql.Timestamp;
13+
import java.time.*;
14+
import java.time.format.DateTimeFormatter;
15+
import java.util.List;
16+
import java.util.Objects;
17+
18+
@Service
19+
@RequiredArgsConstructor
20+
@Slf4j
21+
public class DailyAggregateGenerator {
22+
private final JdbcTemplate jdbcTemplate;
23+
private final TimeIntervalAggregationService intervalAggregationService;
24+
private final MessagesMetricRepository messagesMetricRepository;
25+
26+
/**
27+
* Scheduled task that generates aggregate data for the previous day.
28+
*/
29+
@Scheduled(cron = "${insights-bot.tasks.daily-aggregate-generator}")
30+
@Transactional
31+
public void generateDayAggregates() {
32+
LocalDateTime now = LocalDateTime.now(ZoneId.of("UTC"));
33+
LocalDate yesterday = now.toLocalDate().minusDays(1);
34+
log.info("Generating daily aggregate data for {}.", yesterday.format(DateTimeFormatter.ISO_LOCAL_DATE));
35+
Instant start = yesterday.atStartOfDay().toInstant(ZoneOffset.UTC);
36+
Instant end = yesterday.atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC);
37+
log.info("Start: {}", start.toString());
38+
log.info("End: {}", end.toString());
39+
long startMillis = System.currentTimeMillis();
40+
List<Long> guildIds = this.jdbcTemplate.query(
41+
Objects.requireNonNull(SqlHelper.load("sql/find_guild_ids.sql")),
42+
(resultSet, i) -> resultSet.getLong(1),
43+
Timestamp.from(start), Timestamp.from(end)
44+
);
45+
for (Long guildId : guildIds) {
46+
log.info("Generating data for guild {}:", guildId);
47+
if (this.messagesMetricRepository.existsByGuildIdEqualsAndStartTimestampEqualsAndEndTimestampEquals(guildId, start, end)) {
48+
log.info("\tGuild already has messages metric for this day, skipping.");
49+
} else {
50+
this.intervalAggregationService.generateMessagesMetric(guildId, start, end);
51+
log.info("\tGenerated messages metric.");
52+
}
53+
}
54+
double runtimeSeconds = (System.currentTimeMillis() - startMillis) / 1000.0;
55+
log.info(
56+
"Daily aggregate data generation for {} completed in {} seconds.",
57+
yesterday.format(DateTimeFormatter.ISO_LOCAL_DATE),
58+
String.format("%.3f", runtimeSeconds)
59+
);
60+
}
61+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package net.javadiscord.data.aggregation;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import net.javadiscord.data.dao.MetricRepository;
6+
import net.javadiscord.data.model.stats.time_interval_metrics.MessagesMetric;
7+
import net.javadiscord.util.SqlHelper;
8+
import org.springframework.jdbc.core.JdbcTemplate;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
import java.sql.Timestamp;
13+
import java.time.Instant;
14+
15+
import static net.javadiscord.util.SqlHelper.wrapStr;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
@Slf4j
20+
public class TimeIntervalAggregationService {
21+
private final MetricRepository metricRepository;
22+
private final JdbcTemplate jdbcTemplate;
23+
24+
@Transactional
25+
public MessagesMetric generateMessagesMetric(long guildId, Instant start, Instant end) {
26+
return this.jdbcTemplate.query(connection -> SqlHelper.loadMulti(
27+
connection,
28+
"sql/messages_metric.sql",
29+
wrapStr(Timestamp.from(start).toString()),
30+
wrapStr(Timestamp.from(end).toString()),
31+
String.valueOf(guildId)
32+
), r -> {
33+
if (!r.next()) return null;
34+
MessagesMetric mm = new MessagesMetric(guildId, start, end);
35+
mm.setMessagesCreated(r.getLong("messages_created"));
36+
mm.setMessagesDeleted(r.getLong("messages_deleted"));
37+
mm.setMessagesUpdated(r.getLong("messages_updated"));
38+
mm.setMessagesRetained(r.getLong("messages_retained"));
39+
mm.setReactionsAdded(r.getLong("reactions_added"));
40+
mm.setReactionsRemoved(r.getLong("reactions_removed"));
41+
mm.setActiveUsers(r.getLong("active_users"));
42+
return this.metricRepository.save(mm);
43+
});
44+
}
45+
}

src/main/java/net/javadiscord/data/dao/GuildEventRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package net.javadiscord.data.dao;
22

3-
import net.javadiscord.data.model.GuildEvent;
3+
import net.javadiscord.data.model.events.GuildEvent;
44
import org.springframework.stereotype.Repository;
55

66
@Repository
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package net.javadiscord.data.dao;
2+
3+
import net.javadiscord.data.model.stats.time_interval_metrics.MessagesMetric;
4+
import org.springframework.stereotype.Repository;
5+
6+
import java.time.Instant;
7+
8+
@Repository
9+
public interface MessagesMetricRepository extends BaseEntityRepository<MessagesMetric> {
10+
boolean existsByGuildIdEqualsAndStartTimestampEqualsAndEndTimestampEquals(
11+
long guildId,
12+
Instant start,
13+
Instant end
14+
);
15+
}

0 commit comments

Comments
 (0)