diff --git a/build.gradle b/build.gradle index b642c3b..32dbda6 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ import net.neoforged.gradleutils.PomUtilsExtension import java.nio.file.Files plugins { - id 'java' + id 'java-library' id 'maven-publish' id 'net.neoforged.gradleutils' version '3.0.0-alpha.13' id 'com.github.johnrengelman.shadow' version '7.+' @@ -60,22 +60,6 @@ java { withSourcesJar() } -repositories { - mavenCentral() - maven { - name 'm2-dv8tion' - url 'https://m2.dv8tion.net/releases' - } - maven { - name 'jda-chewtils' - url 'https://m2.chew.pro/releases' - } - maven { - name 'jitpack' - url 'https://jitpack.io' - } -} - compileJava { options.encoding = 'UTF-8' options.compilerArgs.add('--enable-preview') @@ -89,22 +73,27 @@ javadoc { evaluationDependsOn(':config') +configurations { + library.extendsFrom(implementation) + module + runtimeClasspath.extendsFrom(module) +} + dependencies { implementation project(':config') - implementation group: 'com.github.matyrobbrt', name: 'JDA-Chewtils', version: "${project.jda_chewtils_version}" - implementation group: 'net.dv8tion', name: 'JDA', version: "${project.jda_version}" + api group: 'com.github.matyrobbrt', name: 'JDA-Chewtils', version: "${project.jda_chewtils_version}" + api group: 'net.dv8tion', name: 'JDA', version: "${project.jda_version}" implementation group: 'com.google.guava', name: 'guava', version: "${project.guava_version}" implementation group: 'com.google.code.gson', name: 'gson', version: "${project.gson_version}" implementation group: 'ch.qos.logback', name: 'logback-classic', version: "${project.logback_version}" implementation group: 'org.flywaydb', name: 'flyway-core', version: project.flyway_version - implementation group: 'org.jdbi', name: 'jdbi3-core', version: project.jdbi_version - implementation group: 'org.jdbi', name: 'jdbi3-sqlobject', version: project.jdbi_version + implementation libs.bundles.jdbi implementation group: 'org.xerial', name: 'sqlite-jdbc', version: project.sqlite_version // YML parsing and generation - implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: project.jackson_version + implementation libs.bundles.jackson // GitHub API interaction library implementation group: 'org.kohsuke', name: 'github-api', version: project.ghapi_version @@ -144,25 +133,30 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:$junit_version") testImplementation("org.junit.jupiter:junit-jupiter-engine:$junit_version") testImplementation('org.assertj:assertj-core:3.25.1') + + project(':modules').subprojects.each { + library(project(path: it.path, configuration: 'library')) + configurations.module.dependencies.add(dependencies.create(it) { + transitive = false + }) + } } jar { - manifest { - mainAttributes( - 'Maven-Artifact': "${project.group}:${archivesBaseName}:${project.version}", - 'Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), - 'Specification-Title': archivesBaseName, - 'Specification-Vendor': 'NeoForged', - 'Specification-Version': '1', - 'Implementation-Title': archivesBaseName, - 'Implementation-Version': "${project.version}", - 'Implementation-Vendor': 'NeoForged', - 'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), - 'Built-On-Java': "${System.getProperty('java.vm.version')} (${System.getProperty('java.vm.vendor')})", - 'Built-On': "${project.jda_version}-${project.jda_chewtils_version}", - 'Main-Class': 'net.neoforged.camelot.BotMain' - ) - } + manifest.attributes([ + 'Maven-Artifact': "${project.group}:${archivesBaseName}:${project.version}", + 'Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), + 'Specification-Title': archivesBaseName, + 'Specification-Vendor': 'NeoForged', + 'Specification-Version': '1', + 'Implementation-Title': archivesBaseName, + 'Implementation-Version': "${project.version}", + 'Implementation-Vendor': 'NeoForged', + 'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), + 'Built-On-Java': "${System.getProperty('java.vm.version')} (${System.getProperty('java.vm.vendor')})", + 'Built-On': "${project.jda_version}-${project.jda_chewtils_version}", + 'Main-Class': 'net.neoforged.camelot.BotMain' + ]) } tasks.register('outputVersion') { @@ -177,6 +171,7 @@ shadowJar { archiveFile.set(project.file("$buildDir/libs/camelot-all.jar")) dependsOn(tasks.gitLog) from(tasks.gitLog.output) + configurations = [project.configurations.library, project.configurations.module] } test { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000..6784052 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'groovy-gradle-plugin' +} diff --git a/buildSrc/src/main/groovy/camelot-module.gradle b/buildSrc/src/main/groovy/camelot-module.gradle new file mode 100644 index 0000000..8f8dd72 --- /dev/null +++ b/buildSrc/src/main/groovy/camelot-module.gradle @@ -0,0 +1,97 @@ +import java.util.stream.Collectors + +project.plugins.apply('java-library') +project.plugins.apply('groovy') + +var config = project.extensions.create('camelotModule', CamelotModuleConfig) + +var library = project.configurations.create('library') +var moduleConfig = project.configurations.create('module') +project.configurations.getByName('api').extendsFrom(library, moduleConfig) +project.configurations.create('configOut') +project.configurations.create('configOutSrc') + +sourceSets.create('config') + +dependencies { + "configImplementation"(implementation(project(':config'))) + "configOut"(compileOnly(sourceSets.config.output)) + "configOutSrc"(sourceSets.config.allSource) + + compileOnly group: 'com.google.auto.service', name: 'auto-service', version: '1.0.1' + annotationProcessor group: 'com.google.auto.service', name: 'auto-service', version: '1.0.1' + + implementation(project(':')) +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) +java.toolchain.vendor = JvmVendorSpec.GRAAL_VM + +compileJava { + options.encoding = 'UTF-8' + options.compilerArgs.add('--enable-preview') +} + +tasks.jar { + from(sourceSets.config.output) +} + +abstract class CamelotModuleConfig { + @Input + abstract Property getId() +} + +tasks.register('createFiles') { + doLast { + var name = config.id.get().split('-') + .toList().stream().map { it.capitalize() } + .collect(Collectors.joining('')) + var modulePkg = config.id.get().replace('-', '') + writeOrMove(project.file("src/main/java/net/neoforged/camelot/module/${modulePkg}/${name}Module.java"), null, """ +package net.neoforged.camelot.module.${modulePkg}; + +import com.google.auto.service.AutoService; + +import net.neoforged.camelot.config.module.${name}; +import net.neoforged.camelot.module.api.CamelotModule; + +@AutoService(CamelotModule.class) +public class ${name}Module extends CamelotModule.Base<${name}> { + public ${name}Module() { + super(${name}.class); + } + + @Override + public String id() { + return "${config.id.get()}"; + } +}""") + writeOrMove(project.file("src/config/groovy/net/neoforged/camelot/config/module/${name}.groovy"), rootProject.file("config/src/main/groovy/net/neoforged/camelot/config/module/${name}.groovy"), """ +package net.neoforged.camelot.config.module + +import groovy.transform.CompileStatic + +@CompileStatic +class ${name} extends ModuleConfiguration { +}""") + } +} + +tasks.register('createDBFolder') { + doLast { + var modulePkg = config.id.get().replace('-', '') + project.file("src/main/resources/net/neoforged/camelot/module/${modulePkg}/db/schema").mkdirs() + } +} + +private static void writeOrMove(File file, File source, String txt) { + if (source?.exists()) { + file.delete() + file << source.text + source.delete() + } else { + file.delete() + file.parentFile.mkdirs() + file << txt + } +} diff --git a/config/build.gradle b/config/build.gradle index 30ee594..2abcce5 100644 --- a/config/build.gradle +++ b/config/build.gradle @@ -23,10 +23,15 @@ repositories { mavenCentral() } +configurations { + configSources +} + java.withSourcesJar() groovydoc { use = true + source(configurations.configSources) } tasks.register('groovydocJar', Jar) { @@ -38,9 +43,14 @@ tasks.register('groovydocJar', Jar) { dependencies { api "org.apache.groovy:groovy:${project.groovy_version}" api "org.apache.groovy:groovy-contracts:${project.groovy_version}" - implementation group: 'org.kohsuke', name: 'github-api', version: project.ghapi_version + api group: 'org.kohsuke', name: 'github-api', version: project.ghapi_version sampleCompileOnly(sourceSets.main.output) + + project(':modules').subprojects.each { + sampleCompileOnly(project(path: it.path, configuration: 'configOut')) + configSources(project(path: it.path, configuration: 'configOutSrc')) + } } publishing { diff --git a/gradle.properties b/gradle.properties index a9a84a5..d1cebcd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,11 +4,9 @@ guava_version=30.1.1-jre gson_version=2.10.1 logback_version=1.5.6 flyway_version=8.5.13 -jdbi_version=3.32.0 sqlite_version=3.40.0.0 jjwt_version=0.10.5 -jackson_version=2.13.3 trove4j_version=3.0.3 ghapi_version=1.313 bcpkix_version=1.58 diff --git a/modules/file-preview/build.gradle b/modules/file-preview/build.gradle new file mode 100644 index 0000000..4d99b0e --- /dev/null +++ b/modules/file-preview/build.gradle @@ -0,0 +1,7 @@ +plugins { + id 'camelot-module' +} + +camelotModule { + id = 'file-preview' +} diff --git a/config/src/main/groovy/net/neoforged/camelot/config/module/FilePreview.groovy b/modules/file-preview/src/config/groovy/net/neoforged/camelot/config/module/FilePreview.groovy similarity index 100% rename from config/src/main/groovy/net/neoforged/camelot/config/module/FilePreview.groovy rename to modules/file-preview/src/config/groovy/net/neoforged/camelot/config/module/FilePreview.groovy diff --git a/src/main/java/net/neoforged/camelot/module/FilePreviewModule.java b/modules/file-preview/src/main/java/net/neoforged/camelot/module/filepreview/FilePreviewModule.java similarity index 99% rename from src/main/java/net/neoforged/camelot/module/FilePreviewModule.java rename to modules/file-preview/src/main/java/net/neoforged/camelot/module/filepreview/FilePreviewModule.java index 3aa721f..bf0d416 100644 --- a/src/main/java/net/neoforged/camelot/module/FilePreviewModule.java +++ b/modules/file-preview/src/main/java/net/neoforged/camelot/module/filepreview/FilePreviewModule.java @@ -1,4 +1,4 @@ -package net.neoforged.camelot.module; +package net.neoforged.camelot.module.filepreview; import com.google.auto.service.AutoService; import net.dv8tion.jda.api.JDABuilder; diff --git a/modules/info-channels/build.gradle b/modules/info-channels/build.gradle new file mode 100644 index 0000000..1a109c6 --- /dev/null +++ b/modules/info-channels/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'camelot-module' +} + +camelotModule { + id = 'info-channels' +} + +dependencies { + library(libs.bundles.jdbi) + library(libs.bundles.jackson) +} diff --git a/config/src/main/groovy/net/neoforged/camelot/config/module/InfoChannels.groovy b/modules/info-channels/src/config/groovy/net/neoforged/camelot/config/module/InfoChannels.groovy similarity index 100% rename from config/src/main/groovy/net/neoforged/camelot/config/module/InfoChannels.groovy rename to modules/info-channels/src/config/groovy/net/neoforged/camelot/config/module/InfoChannels.groovy diff --git a/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/InfoChannelsModule.java b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/InfoChannelsModule.java new file mode 100644 index 0000000..2aacbb6 --- /dev/null +++ b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/InfoChannelsModule.java @@ -0,0 +1,85 @@ +package net.neoforged.camelot.module.infochannels; + +import com.google.auto.service.AutoService; +import com.jagrosh.jdautilities.command.CommandClientBuilder; +import net.dv8tion.jda.api.JDA; +import net.neoforged.camelot.BotMain; +import net.neoforged.camelot.config.module.InfoChannels; +import net.neoforged.camelot.db.schemas.GithubLocation; +import net.neoforged.camelot.module.BuiltInModule; +import net.neoforged.camelot.module.api.CamelotModule; +import net.neoforged.camelot.module.infochannels.command.InfoChannelCommand; +import net.neoforged.camelot.module.infochannels.command.RuleCommand; +import net.neoforged.camelot.module.infochannels.db.InfoChannel; +import net.neoforged.camelot.module.infochannels.db.InfoChannelsDAO; +import net.neoforged.camelot.module.infochannels.db.RulesDAO; + +import java.util.concurrent.TimeUnit; + +/** + * Info channels module. + */ +@AutoService(CamelotModule.class) +public class InfoChannelsModule extends CamelotModule.WithDatabase { + public InfoChannelsModule() { + super(InfoChannels.class); + + accept(BuiltInModule.DB_MIGRATION_CALLBACKS, builder -> builder + .add(BuiltInModule.DatabaseSource.MAIN, 15, stmt -> { + logger.info("Migrating info channels from main.db to info-channels.db"); + var infoChannels = stmt.executeQuery("select * from info_channels"); + db().useExtension(InfoChannelsDAO.class, db -> { + while (infoChannels.next()) { + db.insert( + new InfoChannel( + infoChannels.getLong(1), + GithubLocation.parse(infoChannels.getString(2)), + infoChannels.getBoolean(3), + infoChannels.getString(4), + InfoChannel.Type.values()[infoChannels.getInt(5)] + ) + ); + } + }); + + logger.info("Migrating rules from main.db to info-channels.db"); + var rules = stmt.executeQuery("select * from rules"); + db().useExtension(RulesDAO.class, db -> { + while (rules.next()) { + db.insert( + rules.getLong(1), + rules.getLong(2), + rules.getInt(3), + rules.getString(4) + ); + } + }); + })); + } + + @Override + public String id() { + return "info-channels"; + } + + @Override + public boolean shouldLoad() { + return config().getAuth() != null; + } + + @Override + public void registerCommands(CommandClientBuilder builder) { + builder.addSlashCommand(new InfoChannelCommand()) + .addSlashCommand(RuleCommand.INSTANCE) + .addCommand(RuleCommand.INSTANCE) + .addContextMenu(new InfoChannelCommand.UploadToDiscohookContextMenu()); + } + + @Override + public void setup(JDA jda) { + jda.addEventListener(InfoChannelCommand.EVENT_LISTENER); + + // Update info channels every couple of minutes + BotMain.EXECUTOR.scheduleAtFixedRate(InfoChannelCommand::run, 1, 2, TimeUnit.MINUTES); + } +} diff --git a/src/main/java/net/neoforged/camelot/commands/information/InfoChannelCommand.java b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/command/InfoChannelCommand.java similarity index 95% rename from src/main/java/net/neoforged/camelot/commands/information/InfoChannelCommand.java rename to modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/command/InfoChannelCommand.java index b1201f7..c2f4c5d 100644 --- a/src/main/java/net/neoforged/camelot/commands/information/InfoChannelCommand.java +++ b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/command/InfoChannelCommand.java @@ -1,4 +1,4 @@ -package net.neoforged.camelot.commands.information; +package net.neoforged.camelot.module.infochannels.command; import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonGenerator; @@ -47,11 +47,10 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateData; import net.dv8tion.jda.api.utils.messages.MessageEditBuilder; import net.neoforged.camelot.BotMain; -import net.neoforged.camelot.Database; import net.neoforged.camelot.db.schemas.GithubLocation; -import net.neoforged.camelot.db.schemas.InfoChannel; -import net.neoforged.camelot.db.transactionals.InfoChannelsDAO; -import net.neoforged.camelot.module.InfoChannelsModule; +import net.neoforged.camelot.module.infochannels.InfoChannelsModule; +import net.neoforged.camelot.module.infochannels.db.InfoChannel; +import net.neoforged.camelot.module.infochannels.db.InfoChannelsDAO; import net.neoforged.camelot.util.Utils; import net.neoforged.camelot.util.jda.WebhookCache; import net.neoforged.camelot.util.jda.WebhookManager; @@ -120,13 +119,14 @@ public Delete() { protected void execute(SlashCommandEvent event) { final MessageChannel messageChannel = event.optMessageChannel("channel", event.getChannel()); - final InfoChannel ch = Database.main().withExtension(InfoChannelsDAO.class, db -> db.getChannel(messageChannel.getIdLong())); + var database = BotMain.getModule(InfoChannelsModule.class).db(); + final InfoChannel ch = database.withExtension(InfoChannelsDAO.class, db -> db.getChannel(messageChannel.getIdLong())); if (ch == null) { event.reply("The channel is not an info channel!").setEphemeral(true).queue(); return; } - Database.main().useExtension(InfoChannelsDAO.class, db -> db.delete(messageChannel.getIdLong())); + database.useExtension(InfoChannelsDAO.class, db -> db.delete(messageChannel.getIdLong())); event.reply("The channel is no longer an info channel!").queue(); } } @@ -160,7 +160,7 @@ protected void execute(SlashCommandEvent event) { final InfoChannel ic = new InfoChannel(event.optMessageChannel("channel", event.getChannel()).getIdLong(), location, event.getOption("recreate", false, OptionMapping::getAsBoolean), null, event.getOption("type", InfoChannel.Type.NORMAL, t -> InfoChannel.Type.valueOf(t.getAsString()))); - Database.main().useExtension(InfoChannelsDAO.class, db -> db.insert(ic)); + BotMain.getModule(InfoChannelsModule.class).db().useExtension(InfoChannelsDAO.class, db -> db.insert(ic)); event.reply("Successfully set channel as info channel!") .setEphemeral(true) .delay(5, TimeUnit.SECONDS) @@ -182,7 +182,7 @@ public GetWebhook() { @Override protected void execute(SlashCommandEvent event) { final MessageChannel target = event.optMessageChannel("channel", event.getChannel()); - final InfoChannel ch = Database.main().withExtension(InfoChannelsDAO.class, db -> db.getChannel(target.getIdLong())); + final InfoChannel ch = BotMain.getModule(InfoChannelsModule.class).db().withExtension(InfoChannelsDAO.class, db -> db.getChannel(target.getIdLong())); if (ch == null || !(target instanceof IWebhookContainer web)) { event.reply("The channel is not an info channel with webhook support!").setEphemeral(true).queue(); return; @@ -292,13 +292,14 @@ protected void execute(MessageContextMenuEvent event) { public static void run() { var app = BotMain.getModule(InfoChannelsModule.class).config().getAuth(); - final List channels = Database.main().withExtension(InfoChannelsDAO.class, InfoChannelsDAO::getChannels); + var database = BotMain.getModule(InfoChannelsModule.class).db(); + final List channels = database.withExtension(InfoChannelsDAO.class, InfoChannelsDAO::getChannels); for (final InfoChannel ch : channels) { if (UPDATING_CHANNELS.contains(ch.channel())) return; final MessageChannel messageChannel = BotMain.get().getChannelById(MessageChannel.class, ch.channel()); if (messageChannel == null) { - Database.main().useExtension(InfoChannelsDAO.class, db -> db.delete(ch.channel())); + database.useExtension(InfoChannelsDAO.class, db -> db.delete(ch.channel())); return; } @@ -340,7 +341,7 @@ public static void run() { cf = Utils.whenComplete(cf, () -> cfSend.apply(theMessage)); } cf.whenComplete((o, throwable) -> BotMain.EXECUTOR.schedule(() -> UPDATING_CHANNELS.remove(ch.channel()), 5, TimeUnit.SECONDS)); // Give some wiggle room in case of high latency - Database.main().useExtension(InfoChannelsDAO.class, db -> db.updateHash(ch.channel(), hash)); + database.useExtension(InfoChannelsDAO.class, db -> db.updateHash(ch.channel(), hash)); }); }); } else { @@ -386,7 +387,7 @@ public static void run() { } cf.whenComplete((o, throwable) -> BotMain.EXECUTOR.schedule(() -> UPDATING_CHANNELS.remove(ch.channel()), 5, TimeUnit.SECONDS)); // Give some wiggle room in case of high latency - Database.main().useExtension(InfoChannelsDAO.class, db -> db.updateHash(ch.channel(), hash)); + database.useExtension(InfoChannelsDAO.class, db -> db.updateHash(ch.channel(), hash)); }); } } @@ -443,7 +444,7 @@ private static BiFunction> getMessageEdi } // If the channel isn't an info channel or is being updated, early-exit - final InfoChannel infoChannel = Database.main().withExtension(InfoChannelsDAO.class, db -> db.getChannel(channel.getIdLong())); + final InfoChannel infoChannel = BotMain.getModule(InfoChannelsModule.class).db().withExtension(InfoChannelsDAO.class, db -> db.getChannel(channel.getIdLong())); if (infoChannel == null|| UPDATING_CHANNELS.contains(infoChannel.channel())) return; var app = BotMain.getModule(InfoChannelsModule.class).config().getAuth(); diff --git a/src/main/java/net/neoforged/camelot/commands/information/RuleCommand.java b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/command/RuleCommand.java similarity index 86% rename from src/main/java/net/neoforged/camelot/commands/information/RuleCommand.java rename to modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/command/RuleCommand.java index cd5912f..f8f2884 100644 --- a/src/main/java/net/neoforged/camelot/commands/information/RuleCommand.java +++ b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/command/RuleCommand.java @@ -1,4 +1,4 @@ -package net.neoforged.camelot.commands.information; +package net.neoforged.camelot.module.infochannels.command; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jdautilities.command.SlashCommand; @@ -9,9 +9,10 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; -import net.neoforged.camelot.Database; -import net.neoforged.camelot.db.schemas.Rule; -import net.neoforged.camelot.db.transactionals.RulesDAO; +import net.neoforged.camelot.BotMain; +import net.neoforged.camelot.module.infochannels.InfoChannelsModule; +import net.neoforged.camelot.module.infochannels.db.Rule; +import net.neoforged.camelot.module.infochannels.db.RulesDAO; import java.util.List; @@ -74,6 +75,6 @@ protected void execute(final CommandEvent event) { } protected Rule getRule(Guild guild, int id) { - return Database.main().withExtension(RulesDAO.class, db -> db.getRule(guild.getIdLong(), id)); + return BotMain.getModule(InfoChannelsModule.class).db().withExtension(RulesDAO.class, db -> db.getRule(guild.getIdLong(), id)); } -} \ No newline at end of file +} diff --git a/src/main/java/net/neoforged/camelot/db/schemas/InfoChannel.java b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/InfoChannel.java similarity index 95% rename from src/main/java/net/neoforged/camelot/db/schemas/InfoChannel.java rename to modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/InfoChannel.java index fcfd3ac..681efe4 100644 --- a/src/main/java/net/neoforged/camelot/db/schemas/InfoChannel.java +++ b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/InfoChannel.java @@ -1,4 +1,4 @@ -package net.neoforged.camelot.db.schemas; +package net.neoforged.camelot.module.infochannels.db; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -11,15 +11,14 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import net.neoforged.camelot.BotMain; import net.neoforged.camelot.Database; -import net.neoforged.camelot.commands.information.InfoChannelCommand; -import net.neoforged.camelot.commands.information.RuleCommand; -import net.neoforged.camelot.db.transactionals.InfoChannelsDAO; -import net.neoforged.camelot.db.transactionals.RulesDAO; +import net.neoforged.camelot.module.infochannels.command.RuleCommand; +import net.neoforged.camelot.db.schemas.GithubLocation; +import net.neoforged.camelot.module.infochannels.command.InfoChannelCommand; import org.jdbi.v3.core.mapper.RowMapper; import org.jdbi.v3.core.statement.StatementContext; import org.jetbrains.annotations.Nullable; -import java.awt.*; +import java.awt.Color; import java.io.IOException; import java.sql.ResultSet; import java.sql.SQLException; diff --git a/src/main/java/net/neoforged/camelot/db/transactionals/InfoChannelsDAO.java b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/InfoChannelsDAO.java similarity index 95% rename from src/main/java/net/neoforged/camelot/db/transactionals/InfoChannelsDAO.java rename to modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/InfoChannelsDAO.java index 768c79d..d2393e9 100644 --- a/src/main/java/net/neoforged/camelot/db/transactionals/InfoChannelsDAO.java +++ b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/InfoChannelsDAO.java @@ -1,4 +1,4 @@ -package net.neoforged.camelot.db.transactionals; +package net.neoforged.camelot.module.infochannels.db; import org.jdbi.v3.sqlobject.config.RegisterRowMapper; import org.jdbi.v3.sqlobject.customizer.Bind; @@ -6,7 +6,6 @@ import org.jdbi.v3.sqlobject.statement.SqlUpdate; import org.jdbi.v3.sqlobject.transaction.Transactional; import org.jetbrains.annotations.Nullable; -import net.neoforged.camelot.db.schemas.InfoChannel; import java.util.List; diff --git a/src/main/java/net/neoforged/camelot/db/schemas/Rule.java b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/Rule.java similarity index 91% rename from src/main/java/net/neoforged/camelot/db/schemas/Rule.java rename to modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/Rule.java index 991c1d2..e801585 100644 --- a/src/main/java/net/neoforged/camelot/db/schemas/Rule.java +++ b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/Rule.java @@ -1,4 +1,4 @@ -package net.neoforged.camelot.db.schemas; +package net.neoforged.camelot.module.infochannels.db; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.utils.data.DataObject; @@ -17,7 +17,7 @@ * @param channelId the ID of the channel the rule is in * @param number the number of the rule * @param embed the content of the rule - * @see net.neoforged.camelot.db.transactionals.RulesDAO + * @see RulesDAO */ public record Rule(long guildId, long channelId, int number, MessageEmbed embed) { public static final class Mapper implements RowMapper { diff --git a/src/main/java/net/neoforged/camelot/db/transactionals/RulesDAO.java b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/RulesDAO.java similarity index 72% rename from src/main/java/net/neoforged/camelot/db/transactionals/RulesDAO.java rename to modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/RulesDAO.java index 3e7eb7b..09818c8 100644 --- a/src/main/java/net/neoforged/camelot/db/transactionals/RulesDAO.java +++ b/modules/info-channels/src/main/java/net/neoforged/camelot/module/infochannels/db/RulesDAO.java @@ -1,6 +1,5 @@ -package net.neoforged.camelot.db.transactionals; +package net.neoforged.camelot.module.infochannels.db; -import net.neoforged.camelot.db.schemas.Rule; import org.jdbi.v3.sqlobject.config.RegisterRowMapper; import org.jdbi.v3.sqlobject.customizer.Bind; import org.jdbi.v3.sqlobject.statement.SqlQuery; @@ -27,6 +26,11 @@ public interface RulesDAO extends Transactional { @SqlQuery("select * from rules where guild = :id and number = :number") Rule getRule(@Bind("id") long guildId, @Bind("number") int ruleNumber); + /** + * Inserts a raw rule into the database. + */ + @SqlUpdate("insert or replace into rules (guild, channel, number, value) values (?, ?, ?, ?)") + void insert(long guild, long channel, int number, String value); /** * Inserts an rule into the database. @@ -34,11 +38,6 @@ public interface RulesDAO extends Transactional { * @param rule the rule to insert */ default void insert(Rule rule) { - getHandle().createUpdate("insert or replace into rules (guild, channel, number, value) values (?, ?, ?, ?)") - .bind(0, rule.guildId()) - .bind(1, rule.channelId()) - .bind(2, rule.number()) - .bind(3, rule.embed().toData().toString()) - .execute(); + insert(rule.guildId(), rule.channelId(), rule.number(), rule.embed().toData().toString()); } } diff --git a/modules/info-channels/src/main/resources/net/neoforged/camelot/module/infochannels/db/schema/V1__info_channels.sql b/modules/info-channels/src/main/resources/net/neoforged/camelot/module/infochannels/db/schema/V1__info_channels.sql new file mode 100644 index 0000000..a345d98 --- /dev/null +++ b/modules/info-channels/src/main/resources/net/neoforged/camelot/module/infochannels/db/schema/V1__info_channels.sql @@ -0,0 +1,23 @@ +-- See net.neoforged.camelot.module.infochannels.db.InfoChannel -- +create table info_channels +( + -- the channel ID -- + channel unsigned big int not null primary key, + -- the GitHub directory location -- + location text not null, + -- recreate the entire channel contents on update -- + force_recreate boolean not null, + -- last known content hash -- + hash text, + -- the type of the info channel - normal/rules -- + type int not null +) without rowid; + +create table rules +( + guild unsigned big int not null, + channel unsigned big int not null, + number int not null, + value text not null, + primary key (guild, number) +); diff --git a/settings.gradle b/settings.gradle index 0bc82a3..31a0c27 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,40 @@ plugins { id('org.gradle.toolchains.foojay-resolver-convention') version '0.8.0' } +dependencyResolutionManagement { + repositories { + mavenCentral() + maven { + name 'm2-dv8tion' + url 'https://m2.dv8tion.net/releases' + } + maven { + name 'jda-chewtils' + url 'https://m2.chew.pro/releases' + } + maven { + name 'jitpack' + url 'https://jitpack.io' + } + } + + versionCatalogs { + create('libs') { + final jdbi = version('jdbi', '3.32.0') + library('jdbi-core', 'org.jdbi', 'jdbi3-core').versionRef(jdbi) + library('jdbi-sqlobject', 'org.jdbi', 'jdbi3-sqlobject').versionRef(jdbi) + bundle('jdbi', ['jdbi-core', 'jdbi-sqlobject']) + + final jackson = version('jackson', '2.13.3') + library('jackson-yaml', 'com.fasterxml.jackson.dataformat', 'jackson-dataformat-yaml').versionRef(jackson) + bundle('jackson', ['jackson-yaml']) + } + } +} + rootProject.name = 'Camelot' include ':config' + +include ':modules:info-channels' +include ':modules:file-preview' diff --git a/src/main/java/net/neoforged/camelot/BotMain.java b/src/main/java/net/neoforged/camelot/BotMain.java index b4d14a8..f049542 100644 --- a/src/main/java/net/neoforged/camelot/BotMain.java +++ b/src/main/java/net/neoforged/camelot/BotMain.java @@ -41,6 +41,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; @@ -229,13 +230,13 @@ public static void main(String[] args) { loadConfig(cliConfig.config.toPath()); - modules = Map.copyOf(Stream.concat( + modules = Collections.unmodifiableMap(Stream.concat( builtIn.stream(), allModules.values().stream().filter(module -> module.config().isEnabled() && module.shouldLoad()) ) .collect(Collectors.toMap( CamelotModule::getClass, - camelotModule -> (CamelotModule)camelotModule, + camelotModule -> (CamelotModule) camelotModule, (_, b) -> b, IdentityHashMap::new ))); @@ -256,6 +257,7 @@ public static void main(String[] args) { .setActivity(Activity.customStatus("Listening for your commands")) .setMemberCachePolicy(MemberCachePolicy.ALL); + forEachModule(CamelotModule::init); try { Database.init(); } catch (IOException exception) { diff --git a/src/main/java/net/neoforged/camelot/Database.java b/src/main/java/net/neoforged/camelot/Database.java index e03a050..eee86f1 100644 --- a/src/main/java/net/neoforged/camelot/Database.java +++ b/src/main/java/net/neoforged/camelot/Database.java @@ -7,6 +7,7 @@ import net.neoforged.camelot.db.transactionals.LoggingChannelsDAO; import net.neoforged.camelot.db.transactionals.ThreadPingsDAO; import net.neoforged.camelot.listener.CustomPingListener; +import net.neoforged.camelot.module.BuiltInModule; import org.flywaydb.core.Flyway; import org.flywaydb.core.api.callback.Callback; import org.flywaydb.core.api.callback.Context; @@ -29,6 +30,10 @@ import java.sql.Connection; import java.sql.SQLException; import java.sql.Types; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; import java.util.function.UnaryOperator; /** @@ -108,22 +113,39 @@ static void init() throws IOException { } } - config = createDatabaseConnection(dir.resolve("configuration.db"), "config"); - - main = createDatabaseConnection(mainDb, "main", flyway -> flyway - .callbacks(schemaMigrationCallback(14, connection -> { - LOGGER.info("Migrating logging channels from main.db to configuration.db"); + var callbacks = new EnumMap>(BuiltInModule.DatabaseSource.class); + BotMain.propagateParameter(BuiltInModule.DB_MIGRATION_CALLBACKS, new BuiltInModule.MigrationCallbackBuilder() { + @Override + public BuiltInModule.MigrationCallbackBuilder add(BuiltInModule.DatabaseSource source, int version, BuiltInModule.StatementConsumer consumer) { + callbacks.computeIfAbsent(source, k -> new ArrayList<>()).add(schemaMigrationCallback(version, connection -> { try (var stmt = connection.createStatement()) { - // So uh, while the type in the table is meant to be an int, it was actually a string. The new DB also stores a string - var rs = stmt.executeQuery("select type, channel from logging_channels"); - config.useExtension(LoggingChannelsDAO.class, extension -> { - while (rs.next()) { - extension.insert(rs.getLong(2), LoggingChannelsDAO.Type.valueOf(rs.getString(1))); - } - }); + consumer.accept(stmt); } - }))); - pings = createDatabaseConnection(dir.resolve("pings.db"), "pings", flyway -> flyway + })); + return this; + } + }); + + callbacks.computeIfAbsent(BuiltInModule.DatabaseSource.MAIN, _ -> new ArrayList<>()).add(schemaMigrationCallback(14, connection -> { + LOGGER.info("Migrating logging channels from main.db to configuration.db"); + try (var stmt = connection.createStatement()) { + // So uh, while the type in the table is meant to be an int, it was actually a string. The new DB also stores a string + var rs = stmt.executeQuery("select type, channel from logging_channels"); + config.useExtension(LoggingChannelsDAO.class, extension -> { + while (rs.next()) { + extension.insert(rs.getLong(2), LoggingChannelsDAO.Type.valueOf(rs.getString(1))); + } + }); + } + })); + + config = createDatabaseConnection(dir.resolve("configuration.db"), "config"); + + main = createDatabaseConnection(mainDb, "Camelot DB main", flyway -> flyway + .locations("classpath:db/main") + .callbacks(callbacks.get(BuiltInModule.DatabaseSource.MAIN).toArray(Callback[]::new))); + pings = createDatabaseConnection(dir.resolve("pings.db"), "Camelot DB pings", flyway -> flyway + .locations("classpath:db/pings") .callbacks(schemaMigrationCallback(3, connection -> { LOGGER.info("Migrating thread pings from pings.db to configuration.db"); try (var stmt = connection.createStatement()) { @@ -140,8 +162,9 @@ static void init() throws IOException { CustomPingListener.requestRefresh(); } - public static Jdbi createDatabaseConnection(Path dbPath, String flywayLocation) { - return createDatabaseConnection(dbPath, flywayLocation, UnaryOperator.identity()); + private static Jdbi createDatabaseConnection(Path dbPath, String flywayLocation) { + return createDatabaseConnection(dbPath, "Camelot DB", fluentConfiguration -> fluentConfiguration + .locations("classpath:db/" + flywayLocation)); } /** @@ -149,10 +172,11 @@ public static Jdbi createDatabaseConnection(Path dbPath, String flywayLocation) * * @return a JDBI connection to the database */ - public static Jdbi createDatabaseConnection(Path dbPath, String flywayLocation, UnaryOperator flywayConfig) { + public static Jdbi createDatabaseConnection(Path dbPath, String name, UnaryOperator flywayConfig) { dbPath = dbPath.toAbsolutePath(); if (!Files.exists(dbPath)) { try { + Files.createDirectories(dbPath.getParent()); Files.createFile(dbPath); } catch (IOException e) { throw new RuntimeException("Exception creating database!", e); @@ -162,15 +186,12 @@ public static Jdbi createDatabaseConnection(Path dbPath, String flywayLocation, final SQLiteDataSource dataSource = new SQLiteDataSource(); dataSource.setUrl(url); dataSource.setEncoding("UTF-8"); - dataSource.setDatabaseName("Camelot DB"); + dataSource.setDatabaseName(name); dataSource.setEnforceForeignKeys(true); dataSource.setCaseSensitiveLike(false); LOGGER.info("Initiating SQLite database connection at {}.", url); - final var flyway = flywayConfig.apply(Flyway.configure() - .dataSource(dataSource) - .locations("classpath:db/" + flywayLocation)) - .load(); + final var flyway = flywayConfig.apply(Flyway.configure().dataSource(dataSource)).load(); flyway.migrate(); final Jdbi jdbi = Jdbi.create(dataSource) diff --git a/src/main/java/net/neoforged/camelot/configuration/ConfigMigrator.java b/src/main/java/net/neoforged/camelot/configuration/ConfigMigrator.java index 4ad8781..d4664f0 100644 --- a/src/main/java/net/neoforged/camelot/configuration/ConfigMigrator.java +++ b/src/main/java/net/neoforged/camelot/configuration/ConfigMigrator.java @@ -3,8 +3,6 @@ import net.neoforged.camelot.BotMain; import net.neoforged.camelot.config.module.BanAppeals; import net.neoforged.camelot.config.module.CustomPings; -import net.neoforged.camelot.config.module.FilePreview; -import net.neoforged.camelot.config.module.InfoChannels; import net.neoforged.camelot.config.module.MinecraftVerification; import net.neoforged.camelot.config.module.ModuleConfiguration; import net.neoforged.camelot.config.module.Tricks; @@ -20,13 +18,14 @@ import java.util.Set; import java.util.stream.Collectors; +// TODO - the splitting prevents accessing the other module, we need to find a way to fix it public class ConfigMigrator { @SuppressWarnings("unchecked") private final Map, String> configToId = ServiceLoader.load(CamelotModule.class) .stream().map(ServiceLoader.Provider::get) .collect(Collectors.toMap(CamelotModule::configType, CamelotModule::id)); - public String migrate(Properties properties) { + public String migrate(Properties properties) throws Exception { final Set disabled = Arrays.stream(properties.getProperty("disabledModules", "webserver,mc-verification,ban-appeal").split(",")) .map(String::trim).collect(Collectors.toSet()); @@ -46,12 +45,12 @@ public String migrate(Properties properties) { }); script.module(CustomPings.class, () -> script.appendProperty("pingThreadsChannel", Long.parseLong(properties.getProperty("pingsThreadsChannel", "0")))); - script.module(FilePreview.class, () -> script.appendLine(STR."auth = patAuthentication(secret('\{escape(properties.getProperty("filePreview.gistToken", ""))}'))")); + script.module("net.neoforged.camelot.module.filepreview.FilePreviewModule", () -> script.appendLine(STR."auth = patAuthentication(secret('\{escape(properties.getProperty("filePreview.gistToken", ""))}'))")); script.module(WebServer.class, () -> script .appendProperty("port", Integer.parseInt(properties.getProperty("server.port", "3000"))) .appendProperty("serverUrl", properties.getProperty("server.url", properties.getProperty("server.url")))); - script.module(InfoChannels.class, () -> { + script.module("net.neoforged.camelot.config.module.InfoChannels", () -> { if (Files.exists(Path.of("github.pem"))) { script.appendLine("auth = appAuthentication {").indent(); script.appendProperty("appId", properties.getProperty("githubAppId")); @@ -171,6 +170,10 @@ public PaddedStringBuilder appendOauth(String type) { return this; } + public PaddedStringBuilder module(String className, Runnable appender) throws Exception { + return module((Class) Class.forName(className), appender); + } + public PaddedStringBuilder module(Class type, Runnable appender) { appendLine("module(" + type.getSimpleName() + ") {").indent(); appendLine("enabled = " + !disabled.contains(configToId.get(type))); diff --git a/src/main/java/net/neoforged/camelot/module/BuiltInModule.java b/src/main/java/net/neoforged/camelot/module/BuiltInModule.java index 35fbb1e..1f1950d 100644 --- a/src/main/java/net/neoforged/camelot/module/BuiltInModule.java +++ b/src/main/java/net/neoforged/camelot/module/BuiltInModule.java @@ -13,8 +13,11 @@ import net.neoforged.camelot.module.api.ParameterType; import net.neoforged.camelot.util.Emojis; +import java.sql.SQLException; +import java.sql.Statement; import java.util.ArrayList; import java.util.Arrays; +import java.util.function.Consumer; /** * A module that provides builtin objects and arguments. @@ -22,6 +25,7 @@ @AutoService(CamelotModule.class) public class BuiltInModule extends CamelotModule.Base { public static final ParameterType CONFIGURATION_COMMANDS = ParameterType.get("configuration_commands", ConfigCommandBuilder.class); + public static final ParameterType DB_MIGRATION_CALLBACKS = ParameterType.get("db_migration_callbacks", MigrationCallbackBuilder.class); public BuiltInModule() { super(ModuleConfiguration.BuiltIn.class); @@ -70,4 +74,16 @@ public String id() { public interface ConfigCommandBuilder { ConfigCommandBuilder accept(SlashCommand... child); } + + public interface MigrationCallbackBuilder { + MigrationCallbackBuilder add(DatabaseSource source, int version, StatementConsumer consumer); + } + + public enum DatabaseSource { + MAIN + } + + public interface StatementConsumer { + void accept(Statement statement) throws SQLException; + } } diff --git a/src/main/java/net/neoforged/camelot/module/InfoChannelsModule.java b/src/main/java/net/neoforged/camelot/module/InfoChannelsModule.java deleted file mode 100644 index 09e116d..0000000 --- a/src/main/java/net/neoforged/camelot/module/InfoChannelsModule.java +++ /dev/null @@ -1,48 +0,0 @@ -package net.neoforged.camelot.module; - -import com.google.auto.service.AutoService; -import com.jagrosh.jdautilities.command.CommandClientBuilder; -import net.dv8tion.jda.api.JDA; -import net.neoforged.camelot.BotMain; -import net.neoforged.camelot.commands.information.InfoChannelCommand; -import net.neoforged.camelot.commands.information.RuleCommand; -import net.neoforged.camelot.config.module.InfoChannels; -import net.neoforged.camelot.module.api.CamelotModule; - -import java.util.concurrent.TimeUnit; - -/** - * Info channels module. - */ -@AutoService(CamelotModule.class) -public class InfoChannelsModule extends CamelotModule.Base { - public InfoChannelsModule() { - super(InfoChannels.class); - } - - @Override - public String id() { - return "info-channels"; - } - - @Override - public boolean shouldLoad() { - return config().getAuth() != null; - } - - @Override - public void registerCommands(CommandClientBuilder builder) { - builder.addSlashCommand(new InfoChannelCommand()) - .addSlashCommand(RuleCommand.INSTANCE) - .addCommand(RuleCommand.INSTANCE) - .addContextMenu(new InfoChannelCommand.UploadToDiscohookContextMenu()); - } - - @Override - public void setup(JDA jda) { - jda.addEventListener(InfoChannelCommand.EVENT_LISTENER); - - // Update info channels every couple of minutes - BotMain.EXECUTOR.scheduleAtFixedRate(InfoChannelCommand::run, 1, 2, TimeUnit.MINUTES); - } -} diff --git a/src/main/java/net/neoforged/camelot/module/api/CamelotModule.java b/src/main/java/net/neoforged/camelot/module/api/CamelotModule.java index d2284c9..5d0df49 100644 --- a/src/main/java/net/neoforged/camelot/module/api/CamelotModule.java +++ b/src/main/java/net/neoforged/camelot/module/api/CamelotModule.java @@ -3,9 +3,15 @@ import com.jagrosh.jdautilities.command.CommandClientBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; +import net.neoforged.camelot.Database; import net.neoforged.camelot.config.CamelotConfig; import net.neoforged.camelot.config.module.ModuleConfiguration; +import org.flywaydb.core.api.Location; +import org.jdbi.v3.core.Jdbi; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.nio.file.Path; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; @@ -21,6 +27,13 @@ public interface CamelotModule { */ String id(); + /** + * The earliest entrypoint, used to initialise databases. + */ + default void init() { + + } + /** * Register the commands that are part of this module. * @@ -85,12 +98,14 @@ default boolean shouldLoad() { * @param the configuration type */ abstract class Base implements CamelotModule { + protected final Logger logger; private final Class configType; private final Map, Consumer> parameters = new IdentityHashMap<>(); private C config; protected Base(Class configType) { this.configType = configType; + logger = LoggerFactory.getLogger(getClass()); } protected void accept(ParameterType type, Consumer acceptor) { @@ -117,4 +132,39 @@ public Class configType() { return configType; } } + + /** + * Base class for {@link CamelotModule camelot modules} with a database. + * + * @param the configuration type + */ + abstract class WithDatabase extends Base { + private final String dbId; + private final Location migrationLocation; + + private Jdbi db; + + protected WithDatabase(Class configType) { + this(configType, null, null); + } + + protected WithDatabase(Class configType, String dbId, Location migrationLocation) { + super(configType); + this.dbId = dbId == null ? id() : dbId; + this.migrationLocation = migrationLocation == null ? new Location("classpath:" + getClass().getPackageName().replace(".", "/") + "/db/schema") : migrationLocation; + } + + public Jdbi db() { + return db; + } + + @Override + public void init() { + try { + db = Database.createDatabaseConnection(Path.of("data/" + dbId + ".db"), "Module " + id() + " database", flyway -> flyway.locations(migrationLocation)); + } catch (Exception exception) { + throw new RuntimeException("Encountered exception setting up database connections for module '" + id() + "':", exception); + } + } + } } diff --git a/src/main/resources/db/main/V15__move_info_channels.sql b/src/main/resources/db/main/V15__move_info_channels.sql new file mode 100644 index 0000000..c1e927a --- /dev/null +++ b/src/main/resources/db/main/V15__move_info_channels.sql @@ -0,0 +1,2 @@ +drop table info_channels; +drop table rules;