From a9b0d5900a2b703d4aef1e9bbe8d2e352af58d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AD=B1=E5=82=91?= <840465812@qq.com> Date: Sun, 24 Oct 2021 18:06:59 +0800 Subject: [PATCH] Complete basic search function - search mods/modPacks/resourcePacks/worlds - Show all files of the addon - Show change log of addon file --- .../kotlin/me/jie65535/jcf/CurseClient.kt | 32 ++-- .../kotlin/me/jie65535/jcf/JCurseforge.kt | 5 +- src/main/kotlin/me/jie65535/jcf/JcfCommand.kt | 41 ++++- .../kotlin/me/jie65535/jcf/MessageHandler.kt | 88 ++++++++++ .../me/jie65535/jcf/MinecraftService.kt | 157 ++++++++++++++++++ .../jcf/model/addon/AddonSortMethod.kt | 35 +++- .../kotlin/me/jie65535/jcf/util/HttpUtil.kt | 29 ++++ 7 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/me/jie65535/jcf/MessageHandler.kt create mode 100644 src/main/kotlin/me/jie65535/jcf/MinecraftService.kt create mode 100644 src/main/kotlin/me/jie65535/jcf/util/HttpUtil.kt diff --git a/src/main/kotlin/me/jie65535/jcf/CurseClient.kt b/src/main/kotlin/me/jie65535/jcf/CurseClient.kt index 8cc41c5..a65ac42 100644 --- a/src/main/kotlin/me/jie65535/jcf/CurseClient.kt +++ b/src/main/kotlin/me/jie65535/jcf/CurseClient.kt @@ -7,6 +7,7 @@ import io.ktor.client.request.* import io.ktor.http.* import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import me.jie65535.jcf.model.addon.* import me.jie65535.jcf.model.addon.file.* @@ -18,10 +19,9 @@ import me.jie65535.jcf.model.game.* import me.jie65535.jcf.model.minecraft.* import me.jie65535.jcf.model.minecraft.modloader.* import me.jie65535.jcf.util.Date -import me.jie65535.jcf.util.internal.DateSerializer @OptIn(ExperimentalSerializationApi::class) -class CurseClient { +object CurseClient { private val json = Json { isLenient = true ignoreUnknownKeys = true @@ -53,7 +53,7 @@ class CurseClient { } suspend fun getGameDatabaseTimestamp(): Date { - return json.decodeFromString(http.get("api/v2/game/timestamp")) + return http.get("api/v2/game/timestamp") } suspend fun getAddon(projectId: Int): Addon { @@ -64,7 +64,7 @@ class CurseClient { return json.decodeFromString( http.post("api/v2/addon") { contentType(ContentType.Application.Json) - body = projectIds + body = json.encodeToString(projectIds) } ) } @@ -72,7 +72,7 @@ class CurseClient { suspend fun getAddons(vararg projectIds: Int): List = getAddons(projectIds.toList()) suspend fun getAddonDescription(projectId: Int): String { - return json.decodeFromString(http.get("api/v2/addon/$projectId/description")) + return http.get("api/v2/addon/$projectId/description") } suspend fun getAddonFiles(projectId: Int): List { @@ -83,7 +83,7 @@ class CurseClient { return json.decodeFromString( http.post("api/v2/addon/files") { contentType(ContentType.Application.Json) - body = keys + body = json.encodeToString(keys) } ) } @@ -91,7 +91,7 @@ class CurseClient { suspend fun getAddonFiles(vararg keys: Int): Map> = getAddonFiles(keys.toList()) suspend fun getAddonFileDownloadUrl(projectId: Int, fileId: Int): String { - return json.decodeFromString(http.get("api/v2/addon/$projectId/file/$fileId/download-url")) + return http.get("api/v2/addon/$projectId/file/$fileId/download-url") } suspend fun getAddonFile(projectId: Int, fileId: Int): AddonFile { @@ -106,7 +106,7 @@ class CurseClient { sortDescending: Boolean = true, gameVersion: String? = null, index: Int = 0, - pageSize: Int = 1000, + pageSize: Int = 10, searchFilter: String? = null ): List { return json.decodeFromString( @@ -134,7 +134,7 @@ class CurseClient { return json.decodeFromString( http.post("api/v2/addon/featured") { contentType(ContentType.Application.Json) - body = FeaturedAddonsRequest(gameId, featuredCount, popularCount, updatedCount, excludedAddons) + body = json.encodeToString(FeaturedAddonsRequest(gameId, featuredCount, popularCount, updatedCount, excludedAddons)) } ) } @@ -168,14 +168,14 @@ class CurseClient { } suspend fun getCategoryDatabaseTimestamp(): Date { - return json.decodeFromString(http.get("api/v2/category/timestamp")) + return http.get("api/v2/category/timestamp") } suspend fun getFingerprintMatches(fingerprints: Collection): FingerprintMatchResult { return json.decodeFromString( http.post("api/v2/fingerprint") { contentType(ContentType.Application.Json) - body = fingerprints + body = json.encodeToString(fingerprints) } ) } @@ -186,7 +186,7 @@ class CurseClient { return json.decodeFromString( http.post("api/v2/fingerprint/fuzzy") { contentType(ContentType.Application.Json) - body = FuzzyMatchesRequest(gameId, fingerprints) + body = json.encodeToString(FuzzyMatchesRequest(gameId, fingerprints)) } ) } @@ -208,7 +208,7 @@ class CurseClient { } suspend fun getModloadersDatabaseTimestamp(): Date { - return json.decodeFromString(http.get("api/v2/minecraft/modloader/timestamp")) + return http.get("api/v2/minecraft/modloader/timestamp") } suspend fun getMinecraftVersions(): List { @@ -220,6 +220,10 @@ class CurseClient { } suspend fun getMinecraftVersionsDatabaseTimestamp(): Date { - return json.decodeFromString(http.get("api/v2/minecraft/version/timestamp")) + return http.get("api/v2/minecraft/version/timestamp") + } + + suspend fun getAddonFileChangeLog(addonId: Int, fileId: Int): String { + return http.get("api/v2/addon/${addonId}/file/${fileId}/changelog") } } \ No newline at end of file diff --git a/src/main/kotlin/me/jie65535/jcf/JCurseforge.kt b/src/main/kotlin/me/jie65535/jcf/JCurseforge.kt index 99ee390..e3dc76e 100644 --- a/src/main/kotlin/me/jie65535/jcf/JCurseforge.kt +++ b/src/main/kotlin/me/jie65535/jcf/JCurseforge.kt @@ -13,7 +13,10 @@ object JCurseforge : KotlinPlugin( version = "0.1.0", ) { author("jie65535") - info("""Curseforge Util""") + info(""" + MC Curseforge Util + https://github.com/jie65535/mirai-console-jcf-plugin + """.trimIndent()) } ) { override fun onEnable() { diff --git a/src/main/kotlin/me/jie65535/jcf/JcfCommand.kt b/src/main/kotlin/me/jie65535/jcf/JcfCommand.kt index d953790..d3556ea 100644 --- a/src/main/kotlin/me/jie65535/jcf/JcfCommand.kt +++ b/src/main/kotlin/me/jie65535/jcf/JcfCommand.kt @@ -2,17 +2,52 @@ package me.jie65535.jcf import net.mamoe.mirai.console.command.CommandSender import net.mamoe.mirai.console.command.CompositeCommand +import net.mamoe.mirai.console.command.UserCommandSender +import net.mamoe.mirai.console.plugin.author +import net.mamoe.mirai.console.plugin.info +import net.mamoe.mirai.console.plugin.name +import net.mamoe.mirai.console.plugin.version object JcfCommand : CompositeCommand( JCurseforge, "jcf", description = "Curseforge Util" ) { - private val curse = CurseClient() - @SubCommand @Description("帮助") suspend fun CommandSender.help() { - sendMessage(usage) + sendMessage("${JCurseforge.name} by ${JCurseforge.author} ${JCurseforge.version}\n" + + "${JCurseforge.info}\n" + usage) } + @SubCommand("ss") + @Description("直接搜索") + suspend fun UserCommandSender.search(filter: String) { + MinecraftService.search(this, MinecraftService.ALL, filter) + } + + @SubCommand("ssmod") + @Description("搜索模组") + suspend fun UserCommandSender.searchMods(filter: String) { + MinecraftService.search(this, MinecraftService.SECTION_ID_MODES, filter) + } + + @SubCommand("sspack") + @Description("搜索整合包") + suspend fun UserCommandSender.searchModPacks(filter: String) { + MinecraftService.search(this, MinecraftService.SECTION_ID_MODE_PACKS, filter) + } + + @SubCommand("ssres") + @Description("搜索资源包") + suspend fun UserCommandSender.searchResourcePacks(filter: String) { + MinecraftService.search(this, MinecraftService.SECTION_ID_RESOURCE_PACKS, filter) + } + + // 可能用不上,先注释掉,有需要再说 +// @SubCommand("ssworld") +// @Description("搜索存档") +// suspend fun UserCommandSender.searchWorlds(filter: String) { +// MinecraftService.search(this, MinecraftService.SECTION_ID_WORLDS, filter) +// } + } \ No newline at end of file diff --git a/src/main/kotlin/me/jie65535/jcf/MessageHandler.kt b/src/main/kotlin/me/jie65535/jcf/MessageHandler.kt new file mode 100644 index 0000000..5539811 --- /dev/null +++ b/src/main/kotlin/me/jie65535/jcf/MessageHandler.kt @@ -0,0 +1,88 @@ +package me.jie65535.jcf + +import me.jie65535.jcf.model.addon.Addon +import me.jie65535.jcf.model.addon.file.AddonFile +import me.jie65535.jcf.util.HttpUtil +import net.mamoe.mirai.contact.Contact +import net.mamoe.mirai.message.data.* +import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource +import java.text.DecimalFormat +import kotlin.math.min + +object MessageHandler { + + fun parseSearchResult(addons: List, builder: ForwardMessageBuilder, contact: Contact) { + for ((index, addon) in addons.withIndex()) { + builder.add(contact.bot.id, index.toString(), + PlainText(""" + $index | [${addon.name}] by ${addon.authors[0].name} + ${formatCount(addon.downloadCount)} Downloads Updated ${addon.dateModified.toLocalDate()} + ${addon.summary} + ${addon.websiteUrl} + """.trimIndent())) + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun parseAddon(addon: Addon, contact: Contact): ForwardMessageBuilder { + val builder = ForwardMessageBuilder(contact) + .add(contact.bot, loadImage(addon.attachments.find{ it.isDefault }!!.thumbnailUrl, contact)) + .add(contact.bot, PlainText(""" + ${addon.name} + 作者:${addon.authors[0].name} + 概括:${addon.summary} + 项目ID:${addon.id} + 创建时间:${addon.dateCreated.toLocalDate()} + 最近更新:${addon.dateModified.toLocalDate()} + 总下载:${addon.downloadCount.toLong()} + 主页:${addon.websiteUrl} + """.trimIndent())) + if (addon.latestFiles.isNotEmpty()) { + builder.add(contact.bot, PlainText("最新文件列表:")) + parseAddonFiles(addon.latestFiles, builder, contact) + } + return builder + } + + fun parseAddonFiles(addonFiles: Collection, builder: ForwardMessageBuilder, contact: Contact) { + for ((index, file) in addonFiles.withIndex()) { + builder.add(contact.bot.id, index.toString(), PlainText(""" + $index | ${file.displayName} [${file.releaseType}] [${file.fileDate.toLocalDate()}] + ${file.downloadUrl} + """.trimIndent())) + } + } + + private const val ONE_GRP_SIZE = 5000 + private const val ONE_MSG_SIZE = 500 + suspend fun sendLargeMessage(contact: Contact, message: String) { + for (g in message.indices step ONE_GRP_SIZE) { + val builder = ForwardMessageBuilder(contact) + for (i in g until g + min(ONE_GRP_SIZE, message.length-g) step ONE_MSG_SIZE) { + builder.add(contact.bot, PlainText(message.subSequence(i, i+(min(ONE_MSG_SIZE, message.length-i))))) + } + contact.sendMessage(builder.build()) + } + } + + private val singleDecimalFormat = DecimalFormat("0.#") + private fun formatCount(count: Double): String = when { + count < 1000000 -> singleDecimalFormat.format(count / 1000) + "K" + count < 1000000000 -> singleDecimalFormat.format(count / 1000000) + "M" + else -> count.toString() + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun loadImage(url: String, contact: Contact): Image { + val imgFileName = url.substringAfterLast("/") + val file = JCurseforge.resolveDataFile("cache/$imgFileName") + val res = if (file.exists()) { + file.readBytes().toExternalResource() + } else { + HttpUtil.downloadImage(url, file).toExternalResource() + } + val image = contact.uploadImage(res) + res.close() + return image + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/jie65535/jcf/MinecraftService.kt b/src/main/kotlin/me/jie65535/jcf/MinecraftService.kt new file mode 100644 index 0000000..73893bb --- /dev/null +++ b/src/main/kotlin/me/jie65535/jcf/MinecraftService.kt @@ -0,0 +1,157 @@ +package me.jie65535.jcf + +import kotlinx.coroutines.TimeoutCancellationException +import me.jie65535.jcf.model.addon.Addon +import me.jie65535.jcf.model.addon.AddonSortMethod +import me.jie65535.jcf.model.addon.file.AddonFile +import net.mamoe.mirai.console.command.UserCommandSender +import net.mamoe.mirai.event.events.MessageEvent +import net.mamoe.mirai.event.nextEventAsync +import net.mamoe.mirai.message.data.ForwardMessageBuilder +import net.mamoe.mirai.message.data.PlainText +import net.mamoe.mirai.message.data.content +import net.mamoe.mirai.utils.MiraiExperimentalApi +import java.lang.Exception +import java.util.regex.Pattern + +@OptIn(MiraiExperimentalApi::class) +object MinecraftService { + private val curseClient = CurseClient + + private const val GAME_ID_MINECRAFT = 432 + const val SECTION_ID_MODES = 6 + const val SECTION_ID_RESOURCE_PACKS = 12 + const val SECTION_ID_WORLDS = 17 + const val SECTION_ID_MODE_PACKS = 4471 + const val ALL = -1 + + private const val WAIT_REPLY_TIMEOUT_MS = 60000L + private const val PAGE_SIZE = 10 + private const val NEXT_PAGE_KEYWORD = "n" + private const val SHOW_FILES_KEYWORD = "files" + + suspend fun search(sender: UserCommandSender, sectionId: Int, filter: String) { + val addon: Addon + var pageIndex = 0 + while (true) { + val searchResult = doSearch(sectionId, filter, pageIndex++, PAGE_SIZE) + val hasNextPage = searchResult.size == PAGE_SIZE + + if (searchResult.isEmpty()) { + sender.sendMessage("未搜索到结果,请更换关键字重试。") + return + } else if (searchResult.size == 1) { + addon = searchResult[0] + break + } + + val builder = ForwardMessageBuilder(sender.subject) + builder.add(sender.bot, PlainText("${WAIT_REPLY_TIMEOUT_MS/1000}秒内回复编号查看")) + MessageHandler.parseSearchResult(searchResult, builder, sender.subject) + if (hasNextPage) builder.add(sender.bot, PlainText("回复[$NEXT_PAGE_KEYWORD]下一页")) + sender.sendMessage(builder.build()) + + try { + val nextEvent = sender.nextEventAsync( + WAIT_REPLY_TIMEOUT_MS, + coroutineContext = sender.coroutineContext + ) { it.sender == sender.user } .await() + if (hasNextPage && nextEvent.message.contentEquals(NEXT_PAGE_KEYWORD, true)) + continue + addon = searchResult[nextEvent.message.content.toInt()] + break + } catch (e: TimeoutCancellationException) { + sender.sendMessage("等待回复超时,请重新查询。") + } catch (e: NumberFormatException) { + sender.sendMessage("请正确回复序号,此次查询已取消,请重新查询。") + } catch (e: IndexOutOfBoundsException) { + sender.sendMessage("请回复正确的序号,此次查询已取消,请重新查询。") + } catch (e: Exception) { + sender.sendMessage("内部发生异常,此次查询已取消,请重新查询。") + throw e + } + return + } + + // addon handle + showAddon(sender, addon) + } + + private suspend fun showAddon(sender: UserCommandSender, addon: Addon) { + val builder = MessageHandler.parseAddon(addon, sender.subject) + if (addon.latestFiles.isNotEmpty()) + builder.add(sender.bot, PlainText("${WAIT_REPLY_TIMEOUT_MS/1000}秒内回复[${SHOW_FILES_KEYWORD}]查看所有文件,回复文件序号查看更新日志")) + sender.sendMessage(builder.build()) + + try { + val nextEvent = sender.nextEventAsync( + WAIT_REPLY_TIMEOUT_MS, + coroutineContext = sender.coroutineContext + ) { it.sender == sender.user } .await() + if (nextEvent.message.contentEquals(SHOW_FILES_KEYWORD, true)) { + showAllFiles(sender, addon) + } else { + val file = addon.latestFiles[nextEvent.message.content.toInt()] + showChangedLog(sender, addon.id, file) + } + } catch (e: TimeoutCancellationException) { + sender.sendMessage("等待回复超时,请重新查询。") + } catch (e: NumberFormatException) { + sender.sendMessage("请正确回复序号,此次查询已取消,请重新查询。") + } catch (e: IndexOutOfBoundsException) { + sender.sendMessage("请回复正确的序号,此次查询已取消,请重新查询。") + } catch (e: Exception) { + sender.sendMessage("内部发生异常,此次查询已取消,请重新查询。") + throw e + } + } + + private suspend fun showAllFiles(sender: UserCommandSender, addon: Addon) { + val files = CurseClient.getAddonFiles(addon.id).sortedByDescending { f -> f.fileDate } + if (files.isEmpty()) { + sender.sendMessage("没有任何文件 :(") + return + } + val builder = ForwardMessageBuilder(sender.subject) + builder.add(sender.bot, PlainText("${WAIT_REPLY_TIMEOUT_MS/1000}秒内回复文件序号查看更新日志")) + MessageHandler.parseAddonFiles(files, builder, sender.subject) + sender.sendMessage(builder.build()) + + try { // cv大法好,回复功能有功夫再封装,先复制粘贴用着 XD + val nextEvent = sender.nextEventAsync( + WAIT_REPLY_TIMEOUT_MS, + coroutineContext = sender.coroutineContext + ) { it.sender == sender.user } .await() + val file = files[nextEvent.message.content.toInt()] + showChangedLog(sender, addon.id, file) + } catch (e: TimeoutCancellationException) { + sender.sendMessage("等待回复超时,请重新查询。") + } catch (e: NumberFormatException) { + sender.sendMessage("请正确回复序号,此次查询已取消,请重新查询。") + } catch (e: IndexOutOfBoundsException) { + sender.sendMessage("请回复正确的序号,此次查询已取消,请重新查询。") + } + } + + private val HTMLPattern = Pattern.compile("<[^>]+>", Pattern.CASE_INSENSITIVE) + private suspend fun showChangedLog(sender: UserCommandSender, addonId: Int, addonFile: AddonFile) { + val changeLogHTML = curseClient.getAddonFileChangeLog(addonId, addonFile.id) + val changeLog = HTMLPattern.matcher(changeLogHTML).replaceAll("") + MessageHandler.sendLargeMessage(sender.subject, changeLog) + } + + private suspend fun doSearch(sectionId: Int, filter: String, pageIndex: Int, pageSize: Int): List { + return curseClient.searchAddons( + gameId = GAME_ID_MINECRAFT, + sectionId = sectionId, + categoryId = -1, + sort = AddonSortMethod.POPULARITY, + sortDescending = true, + gameVersion = null, + index = pageIndex, + pageSize = pageSize, + searchFilter = filter, + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/me/jie65535/jcf/model/addon/AddonSortMethod.kt b/src/main/kotlin/me/jie65535/jcf/model/addon/AddonSortMethod.kt index 8631c2f..edc24b9 100644 --- a/src/main/kotlin/me/jie65535/jcf/model/addon/AddonSortMethod.kt +++ b/src/main/kotlin/me/jie65535/jcf/model/addon/AddonSortMethod.kt @@ -7,14 +7,47 @@ package me.jie65535.jcf.model.addon - +/** + * 附加排序方法 + */ enum class AddonSortMethod { + /** + * 精选 + */ FEATURED, + + /** + * 人气 + */ POPULARITY, + + /** + * 最后更新时间 + */ LAST_UPDATED, + + /** + * 名称 + */ NAME, + + /** + * 作者 + */ AUTHOR, + + /** + * 总下载数 + */ TOTAL_DOWNLOADS, + + /** + * 类别 + */ CATEGORY, + + /** + * 游戏版本 + */ GAME_VERSION } \ No newline at end of file diff --git a/src/main/kotlin/me/jie65535/jcf/util/HttpUtil.kt b/src/main/kotlin/me/jie65535/jcf/util/HttpUtil.kt new file mode 100644 index 0000000..d983cbe --- /dev/null +++ b/src/main/kotlin/me/jie65535/jcf/util/HttpUtil.kt @@ -0,0 +1,29 @@ +package me.jie65535.jcf.util + +//import okhttp3.MediaType +//import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.concurrent.TimeUnit + +object HttpUtil { +// private val JSON: MediaType? = "application/json; charset=utf-8".toMediaTypeOrNull() + private val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .readTimeout(10, TimeUnit.SECONDS) + .build() + } + + /** + * ### 下载图片 + */ + fun downloadImage(url: String, file: File): ByteArray { + val request = Request.Builder().url(url).build() + val imageByte = okHttpClient.newCall(request).execute().body!!.bytes() + val fileParent = file.parentFile + if (!fileParent.exists()) fileParent.mkdirs() + file.writeBytes(imageByte) + return imageByte + } +} \ No newline at end of file