Skip to content

Commit 3f1d0a4

Browse files
committed
Add newsletter module
This monitors a feed of items and adds/updates/deletes messages in the Discord.
1 parent 2c67243 commit 3f1d0a4

13 files changed

Lines changed: 396 additions & 0 deletions

File tree

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ Discord id with their membership.
3535

3636
The member portal can also send a list of roles to assign the user in the response.
3737

38+
### Newsletter
39+
Allows the bot to post newsletter items from a JSON feed to Discord.
40+
41+
The bot will poll a JSON feed at a configurable interval. Each item in the feed has an `id`, and may have a `title`,
42+
`body` (HTML), and `image` (relative URL). For each new item, the bot will convert the body from HTML to markdown,
43+
download and attach any image, and post a message in the configured channel. If a title is present, it is prepended
44+
as a heading. HTML entities in the title are decoded.
45+
46+
If an item's content changes in a subsequent poll, the bot will edit the existing Discord message. If an item is removed
47+
from the feed, the bot will delete the corresponding Discord message. Messages that exceed Discord's 2000 character
48+
limit are truncated.
49+
3850
### Programme
3951
Allows the bot to post programme items from a JSON file to Discord.
4052

@@ -184,6 +196,30 @@ membership:
184196
additionalRoles:
185197
<name in the member portal>: <discord role name>
186198

199+
# Enable the newsletter module.
200+
# Optional. If not provided, the module will be disabled.
201+
newsletter:
202+
# Name of the channel to post newsletter messages to.
203+
# The bot must have the following permissions on the channel:
204+
# * Send Messages
205+
# * Attach Files
206+
# * Manage Messages
207+
# Required.
208+
# e.g. newsletter
209+
channel: <channel name>
210+
211+
# URL of the newsletter JSON feed.
212+
# Should return a JSON array of objects with `id`, and optionally `title`, `body` (HTML), and `image` (relative URL) fields.
213+
# Required.
214+
# e.g. https://example.com/newsletter/discord.json
215+
feedUrl: <url>
216+
217+
# How often to poll the feed for changes.
218+
# Expressed as an ISO8601 duration without the leading `PT`.
219+
# Required.
220+
# e.g. 5m
221+
pollInterval: <duration>
222+
187223
# Enable the programme module.
188224
# Optional. If not provided, the module will be disabled.
189225
programme:

src/main/java/com/ajanuary/watson/Bot.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.ajanuary.watson.membership.MembersApiClient;
88
import com.ajanuary.watson.membership.MembershipChecker;
99
import com.ajanuary.watson.membership.MembershipModule;
10+
import com.ajanuary.watson.newsletter.NewsletterModule;
1011
import com.ajanuary.watson.notification.EventDispatcher;
1112
import com.ajanuary.watson.notification.ReadyEvent;
1213
import com.ajanuary.watson.portalapi.PortalApiClient;
@@ -80,6 +81,11 @@ public Bot(Config config) throws InterruptedException {
8081
new MembershipChecker(jda, membershipConfig, config, apiClient, databaseManager);
8182
new MembershipModule(jda, config, membershipChecker, eventDispatcher);
8283
});
84+
config
85+
.newsletter()
86+
.ifPresent(
87+
newsletterConfig ->
88+
new NewsletterModule(jda, newsletterConfig, config, databaseManager));
8389
config
8490
.programme()
8591
.ifPresent(

src/main/java/com/ajanuary/watson/config/Config.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.ajanuary.watson.alarms.AlarmsConfig;
44
import com.ajanuary.watson.api.ApiConfig;
55
import com.ajanuary.watson.membership.MembershipConfig;
6+
import com.ajanuary.watson.newsletter.NewsletterConfig;
67
import com.ajanuary.watson.programme.ProgrammeConfig;
78
import com.ajanuary.watson.utils.JDAUtils;
89
import java.time.ZoneId;
@@ -18,6 +19,7 @@ public record Config(
1819
Optional<AlarmsConfig> alarms,
1920
Optional<ApiConfig> api,
2021
Optional<MembershipConfig> membership,
22+
Optional<NewsletterConfig> newsletter,
2123
Optional<ProgrammeConfig> programme) {
2224

2325
public void validateDiscordConfig(JDA jda) {
@@ -33,6 +35,7 @@ public void validateDiscordConfig(JDA jda) {
3335
alarms().ifPresent(alarmsConfig -> alarmsConfig.validateDiscordConfig(jdaUtils));
3436
api().ifPresent(apiConfig -> apiConfig.validateDiscordConfig(jdaUtils));
3537
membership().ifPresent(membershipConfig -> membershipConfig.validateDiscordConfig(jdaUtils));
38+
newsletter().ifPresent(newsletterConfig -> newsletterConfig.validateDiscordConfig(jdaUtils));
3639
programme().ifPresent(programmeConfig -> programmeConfig.validateDiscordConfig(jdaUtils));
3740
}
3841
}

src/main/java/com/ajanuary/watson/config/ConfigYamlParser.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.ajanuary.watson.alarms.AlarmsConfigYamlParser;
44
import com.ajanuary.watson.api.ApiConfigYamlParser;
55
import com.ajanuary.watson.membership.MembershipConfigYamlParser;
6+
import com.ajanuary.watson.newsletter.NewsletterConfigYamlParser;
67
import com.ajanuary.watson.programme.ProgrammeConfigYamlParser;
78
import com.fasterxml.jackson.databind.JsonNode;
89
import java.time.ZoneId;
@@ -22,6 +23,8 @@ public Config parse(JsonNode jsonSecrets, JsonNode jsonConfig) {
2223
var apiConfig = configParser.get("api").object().map(ApiConfigYamlParser::parse);
2324
var membershipConfig =
2425
configParser.get("membership").object().map(MembershipConfigYamlParser::parse);
26+
var newsletterConfig =
27+
configParser.get("newsletter").object().map(NewsletterConfigYamlParser::parse);
2528
var programmeConfig =
2629
configParser.get("programme").object().map(c -> ProgrammeConfigYamlParser.parse(c, timezone));
2730

@@ -34,6 +37,7 @@ public Config parse(JsonNode jsonSecrets, JsonNode jsonConfig) {
3437
alarmsConfig,
3538
apiConfig,
3639
membershipConfig,
40+
newsletterConfig,
3741
programmeConfig);
3842
}
3943
}

src/main/java/com/ajanuary/watson/db/DatabaseManager.java

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

33
import com.ajanuary.watson.alarms.ScheduledDM;
44
import com.ajanuary.watson.alarms.WithId;
5+
import com.ajanuary.watson.newsletter.NewsletterDbItem;
56
import com.ajanuary.watson.programme.DiscordItem;
67
import com.ajanuary.watson.programme.DiscordThread;
78
import com.ajanuary.watson.programme.Status;
@@ -718,5 +719,96 @@ insert into comms_log(discord_message_id) values (?)
718719
throw e;
719720
}
720721
}
722+
723+
public Optional<NewsletterDbItem> getNewsletterItem(String id) throws SQLException {
724+
try (var connection = dataSource.getConnection();
725+
var statement =
726+
connection.prepareStatement(
727+
"""
728+
select
729+
discord_message_id,
730+
content_checksum
731+
from
732+
newsletter_items
733+
where
734+
id = ?
735+
""")) {
736+
statement.setString(1, id);
737+
var rs = statement.executeQuery();
738+
if (!rs.next()) {
739+
return Optional.empty();
740+
}
741+
return Optional.of(
742+
new NewsletterDbItem(id, rs.getString(1), rs.getString(2)));
743+
}
744+
}
745+
746+
public void insertNewsletterItem(String id, String discordMessageId, String contentChecksum)
747+
throws SQLException {
748+
try (var connection = dataSource.getConnection();
749+
var statement =
750+
connection.prepareStatement(
751+
"""
752+
insert into newsletter_items (id, discord_message_id, content_checksum)
753+
values (?, ?, ?)
754+
""")) {
755+
statement.setString(1, id);
756+
statement.setString(2, discordMessageId);
757+
statement.setString(3, contentChecksum);
758+
var rowsAffected = statement.executeUpdate();
759+
if (rowsAffected != 1) {
760+
throw new SQLException(
761+
"Error inserting newsletter item. Expected to insert 1 row but got " + rowsAffected);
762+
}
763+
}
764+
}
765+
766+
public void updateNewsletterItem(String id, String contentChecksum) throws SQLException {
767+
try (var connection = dataSource.getConnection();
768+
var statement =
769+
connection.prepareStatement(
770+
"""
771+
update newsletter_items
772+
set content_checksum = ?
773+
where id = ?
774+
""")) {
775+
statement.setString(1, contentChecksum);
776+
statement.setString(2, id);
777+
var rowsAffected = statement.executeUpdate();
778+
if (rowsAffected != 1) {
779+
throw new SQLException(
780+
"Error updating newsletter item. Expected to update 1 row but got " + rowsAffected);
781+
}
782+
}
783+
}
784+
785+
public void deleteNewsletterItem(String id) throws SQLException {
786+
try (var connection = dataSource.getConnection();
787+
var statement =
788+
connection.prepareStatement(
789+
"""
790+
delete from newsletter_items
791+
where id = ?
792+
""")) {
793+
statement.setString(1, id);
794+
statement.executeUpdate();
795+
}
796+
}
797+
798+
public List<String> getAllNewsletterIds() throws SQLException {
799+
try (var connection = dataSource.getConnection();
800+
var statement =
801+
connection.prepareStatement(
802+
"""
803+
select id from newsletter_items
804+
""")) {
805+
var rs = statement.executeQuery();
806+
var results = new ArrayList<String>();
807+
while (rs.next()) {
808+
results.add(rs.getString(1));
809+
}
810+
return results;
811+
}
812+
}
721813
}
722814
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.ajanuary.watson.newsletter;
2+
3+
import com.ajanuary.watson.utils.JDAUtils;
4+
import java.net.URI;
5+
import java.time.Duration;
6+
import net.dv8tion.jda.api.Permission;
7+
8+
public record NewsletterConfig(String channel, URI feedUrl, Duration pollInterval) {
9+
10+
public void validateDiscordConfig(JDAUtils jdaUtils) {
11+
var textChannel = jdaUtils.getTextChannel(channel());
12+
jdaUtils.checkPermissions(
13+
textChannel,
14+
Permission.MESSAGE_SEND,
15+
Permission.MESSAGE_ATTACH_FILES,
16+
Permission.MESSAGE_MANAGE);
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.ajanuary.watson.newsletter;
2+
3+
import com.ajanuary.watson.config.ConfigParser.ObjectConfigParserWithValue;
4+
import java.net.URI;
5+
import java.time.Duration;
6+
7+
public class NewsletterConfigYamlParser {
8+
9+
private NewsletterConfigYamlParser() {}
10+
11+
public static NewsletterConfig parse(ObjectConfigParserWithValue configParser) {
12+
var channel = configParser.get("channel").string().required().value();
13+
var feedUrl = configParser.get("feedUrl").string().required().map(URI::create);
14+
var pollInterval =
15+
configParser.get("pollInterval").string().required().map(v -> Duration.parse("PT" + v));
16+
return new NewsletterConfig(channel, feedUrl, pollInterval);
17+
}
18+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.ajanuary.watson.newsletter;
2+
3+
public record NewsletterDbItem(String id, String discordMessageId, String contentChecksum) {}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.ajanuary.watson.newsletter;
2+
3+
public record NewsletterItem(String id, String title, String body, String image) {}

0 commit comments

Comments
 (0)