diff --git a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/DownloadPluginsSpec.kt b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/DownloadPluginsSpec.kt index 4234e03..1b93602 100644 --- a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/DownloadPluginsSpec.kt +++ b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/DownloadPluginsSpec.kt @@ -29,6 +29,8 @@ import org.gradle.kotlin.dsl.get import org.gradle.kotlin.dsl.named import org.gradle.kotlin.dsl.newInstance import org.gradle.kotlin.dsl.register +import xyz.jpenilla.runtask.pluginsapi.discord.DiscordApi +import xyz.jpenilla.runtask.pluginsapi.discord.DiscordApiImpl import xyz.jpenilla.runtask.pluginsapi.github.GitHubApi import xyz.jpenilla.runtask.pluginsapi.github.GitHubApiImpl import xyz.jpenilla.runtask.pluginsapi.hangar.HangarApi @@ -67,6 +69,7 @@ public abstract class DownloadPluginsSpec @Inject constructor( registry.registerFactory(ModrinthApi::class) { name -> objects.newInstance(ModrinthApiImpl::class, name) } registry.registerFactory(GitHubApi::class) { name -> objects.newInstance(GitHubApiImpl::class, name) } registry.registerFactory(UrlPluginProvider::class) { name -> objects.newInstance(UrlPluginProviderImpl::class, name) } + registry.registerFactory(DiscordApi::class) { name -> objects.newInstance(DiscordApiImpl::class, name) } register("hangar", HangarApi::class) { url.set(HangarApi.DEFAULT_URL) @@ -76,6 +79,7 @@ public abstract class DownloadPluginsSpec @Inject constructor( } register("github", GitHubApi::class) register("url", UrlPluginProvider::class) + register("discord", DiscordApi::class) } /** @@ -96,6 +100,7 @@ public abstract class DownloadPluginsSpec @Inject constructor( is ModrinthApi -> ModrinthApi::class is GitHubApi -> GitHubApi::class is UrlPluginProvider -> UrlPluginProvider::class + is DiscordApi -> DiscordApi::class else -> throw IllegalStateException("Unknown PluginApi type: ${api.javaClass.name}") } configure(name, type) { @@ -163,6 +168,26 @@ public abstract class DownloadPluginsSpec @Inject constructor( github.configure { add(owner, repo, tag, assetName) } } + // discord extensions + + /** + * Access to the built-in [DiscordApi]. + */ + @get:Internal + public val discord: NamedDomainObjectProvider + get() = named("discord", DiscordApi::class) + + /** + * Add a plugin download from a Discord message link. + * + * @param channelId the Discord channel ID where the message is located + * @param messageId the Discord message ID containing the plugin download link + * @param token the Discord bot token to use for fetching the message + */ + public fun discord(channelId: String, messageId: String, token: String) { + discord.configure { add(channelId, messageId, token) } + } + // url extensions /** diff --git a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginApiDownload.kt b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginApiDownload.kt index b0b0427..c660958 100644 --- a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginApiDownload.kt +++ b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginApiDownload.kt @@ -21,6 +21,7 @@ import org.gradle.api.tasks.Input import xyz.jpenilla.runtask.util.HashingAlgorithm import xyz.jpenilla.runtask.util.calculateHash import xyz.jpenilla.runtask.util.toHexString +import kotlin.io.byteInputStream public sealed class PluginApiDownload @@ -173,3 +174,41 @@ public abstract class UrlDownload : PluginApiDownload() { return toHexString(url.get().byteInputStream().calculateHash(HashingAlgorithm.SHA1)) } } + +public abstract class DiscordApiDownload : PluginApiDownload() { + @get:Input + public abstract val channelId: Property + + @get:Input + public abstract val messageId: Property + + @get:Input + public abstract val token: Property + + override fun toString(): String { + return "DiscordApiDownload{channelId=${channelId.get()}, messageId=${messageId.get()}, token=${"*".repeat(token.get().length)}}" + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (javaClass != other?.javaClass) { + return false + } + + other as DiscordApiDownload + + return channelId.get() == other.channelId.get() && + messageId.get() == other.messageId.get() && + token.get() == other.token.get() + } + + override fun hashCode(): Int { + var result = channelId.get().hashCode() + result = 31 * result + messageId.get().hashCode() + result = 31 * result + token.get().hashCode() + return result + } + +} diff --git a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginDownloadServiceImpl.kt b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginDownloadServiceImpl.kt index 1f1ecfc..ca33b2a 100644 --- a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginDownloadServiceImpl.kt +++ b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/PluginDownloadServiceImpl.kt @@ -19,6 +19,7 @@ package xyz.jpenilla.runtask.pluginsapi import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.kotlinModule import com.fasterxml.jackson.module.kotlin.readValue import org.gradle.api.Project @@ -93,6 +94,7 @@ internal abstract class PluginDownloadServiceImpl : PluginDownloadService { is ModrinthApiDownload -> resolveModrinthPlugin(project, download) is GitHubApiDownload -> resolveGitHubPlugin(project, download) is UrlDownload -> resolveUrl(project, download) + is DiscordApiDownload -> resolveDiscordPlugin(project, download) } } @@ -196,6 +198,83 @@ internal abstract class PluginDownloadServiceImpl : PluginDownloadService { return download(ctx) } + private fun resolveDiscordPlugin(project: Project, download: DiscordApiDownload): Path { + val cacheDir = parameters.cacheDirectory.get().asFile.toPath() + val targetDir = cacheDir.resolve(Constants.DISCORD_PLUGIN_DIR) + + val discordAuthorization = "Bot ${download.token.get()}" + val messageUrl = URI.create("https://discord.com/api/v10/channels/${download.channelId.get()}/messages/${download.messageId.get()}").toURL() + val connection = messageUrl.openConnection() as HttpURLConnection + + try { + connection.instanceFollowRedirects = true + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("User-Agent", Constants.USER_AGENT) + connection.setRequestProperty("Authorization", discordAuthorization) + connection.connect() + val status = connection.responseCode + + if (status in 200..299) { + val responseBody = connection.inputStream.bufferedReader().readText() + val mapper = jacksonObjectMapper() + val response: DiscordMessage = mapper.readValue(responseBody) + val attachments = response.attachments + + if (attachments.isEmpty()) { + throw IllegalStateException("No attachments found in Discord message ${download.messageId.get()} in channel ${download.channelId.get()}") + } + + val firstAttachedJar = attachments.find { it.filename.endsWith(".jar") } + + if(firstAttachedJar == null) { + throw IllegalStateException("No jar file found in Discord message ${download.messageId.get()} in channel ${download.channelId.get()}") + } + + val downloadUrl = firstAttachedJar.url + val urlHash = toHexString(downloadUrl.byteInputStream().calculateHash(HashingAlgorithm.SHA1)) + val version = manifest.urlProvider[urlHash] ?: PluginVersion(fileName = "$urlHash.jar", displayName = downloadUrl) + val targetFile = targetDir.resolve(version.fileName) + + val setter: (PluginVersion) -> Unit = { manifest.urlProvider[urlHash] = it } + val ctx = DownloadCtx(project, "discord.com", downloadUrl, targetDir, targetFile, version, setter, authorization = discordAuthorization) + return download(ctx) + + } else { + throw IllegalStateException("Failed to fetch Discord message ${download.messageId.get()} in channel ${download.channelId.get()}, status code: $status") + } + + } finally { + connection.disconnect() + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class DiscordMessage( + val attachments: Array + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DiscordMessage + + if (!attachments.contentEquals(other.attachments)) return false + + return true + } + + override fun hashCode(): Int { + return attachments.contentHashCode() + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class DiscordAttachment( + val id: String, + val filename: String, + val url: String + ) + private fun download(ctx: DownloadCtx): Path { if (refreshDependencies) { return downloadFile(ctx) @@ -229,6 +308,10 @@ internal abstract class PluginDownloadServiceImpl : PluginDownloadService { connection.setRequestProperty("Accept", "application/octet-stream") connection.setRequestProperty("User-Agent", Constants.USER_AGENT) + if (ctx.authorization != null) { + connection.setRequestProperty("Authorization", ctx.authorization) + } + if (ctx.targetFile.isRegularFile()) { if (ctx.version.lastUpdateCheck > 0 && ctx.version.hash?.check(ctx.targetFile) != false) { // File matches what we expected @@ -280,6 +363,7 @@ internal abstract class PluginDownloadServiceImpl : PluginDownloadService { } } + private fun requireValidJarFile(ctx: DownloadCtx, displayName: String) { if (!ctx.requireValidJar) { return @@ -319,6 +403,7 @@ internal abstract class PluginDownloadServiceImpl : PluginDownloadService { val version: PluginVersion, val setter: (PluginVersion) -> Unit, val requireValidJar: Boolean = true, + val authorization: String? = null ) } diff --git a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/discord/DiscordApi.kt b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/discord/DiscordApi.kt new file mode 100644 index 0000000..9261ec9 --- /dev/null +++ b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/discord/DiscordApi.kt @@ -0,0 +1,22 @@ +package xyz.jpenilla.runtask.pluginsapi.discord + +import xyz.jpenilla.runtask.pluginsapi.DiscordApiDownload +import xyz.jpenilla.runtask.pluginsapi.PluginApi + +/** + * [PluginApi] implementation for Discord message links. + */ +public interface DiscordApi : PluginApi { + /** + * Add a Discord message plugin download. + * + * @param channelId the ID of the Discord channel containing the message + * @param messageId the ID of the Discord message to fetch + * @param token the bot token to use for fetching the message + */ + public fun add( + channelId: String, + messageId: String, + token: String + ) +} diff --git a/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/discord/DiscordApiImpl.kt b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/discord/DiscordApiImpl.kt new file mode 100644 index 0000000..8d7c667 --- /dev/null +++ b/plugin/src/main/kotlin/xyz/jpenilla/runtask/pluginsapi/discord/DiscordApiImpl.kt @@ -0,0 +1,27 @@ +package xyz.jpenilla.runtask.pluginsapi.discord + +import org.gradle.api.model.ObjectFactory +import org.gradle.kotlin.dsl.newInstance +import xyz.jpenilla.runtask.pluginsapi.DiscordApiDownload +import javax.inject.Inject + +public abstract class DiscordApiImpl @Inject constructor(private val name: String, private val objects: ObjectFactory) : DiscordApi { + private val jobs: MutableList = mutableListOf() + + override fun getName(): String = name + + override fun add(channelId: String, messageId: String, token: String) { + val job = objects.newInstance(DiscordApiDownload::class) + job.channelId.set(channelId) + job.messageId.set(messageId) + job.token.set(token) + jobs += job + } + + override fun copyConfiguration(api: DiscordApi) { + jobs.addAll(api.downloads) + } + + override val downloads: Iterable + get() = jobs +} diff --git a/plugin/src/main/kotlin/xyz/jpenilla/runtask/util/Constants.kt b/plugin/src/main/kotlin/xyz/jpenilla/runtask/util/Constants.kt index bf4d892..f4e3390 100644 --- a/plugin/src/main/kotlin/xyz/jpenilla/runtask/util/Constants.kt +++ b/plugin/src/main/kotlin/xyz/jpenilla/runtask/util/Constants.kt @@ -39,6 +39,7 @@ internal object Constants { const val MODRINTH_PLUGIN_DIR = "modrinth" const val GITHUB_PLUGIN_DIR = "github" const val URL_PLUGIN_DIR = "url" + const val DISCORD_PLUGIN_DIR = "discord" const val USER_AGENT = "run-task-gradle-plugin (https://github.com/jpenilla/run-task)" diff --git a/tester/build.gradle.kts b/tester/build.gradle.kts index 16b32ca..7e40ad5 100644 --- a/tester/build.gradle.kts +++ b/tester/build.gradle.kts @@ -20,6 +20,7 @@ val paperPlugins = runPaper.downloadPluginsSpec { github("jpenilla", "MiniMOTD", "v2.1.6", "minimotd-bukkit-2.1.6.jar") hangar("squaremap", "1.3.6") url("https://download.luckperms.net/1593/bukkit/loader/LuckPerms-Bukkit-5.5.8.jar") + discord("1379024292548710400","1379024345845989440", project.property("bot_token") as String) } tasks {