Complete basic search function

- search mods/modPacks/resourcePacks/worlds
 - Show all files of the addon
 - Show change log of addon file
This commit is contained in:
筱傑 2021-10-24 18:06:59 +08:00
parent 40c2bbba1d
commit a9b0d5900a
7 changed files with 368 additions and 19 deletions

View File

@ -7,6 +7,7 @@ import io.ktor.client.request.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import me.jie65535.jcf.model.addon.* import me.jie65535.jcf.model.addon.*
import me.jie65535.jcf.model.addon.file.* 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.*
import me.jie65535.jcf.model.minecraft.modloader.* import me.jie65535.jcf.model.minecraft.modloader.*
import me.jie65535.jcf.util.Date import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
class CurseClient { object CurseClient {
private val json = Json { private val json = Json {
isLenient = true isLenient = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -53,7 +53,7 @@ class CurseClient {
} }
suspend fun getGameDatabaseTimestamp(): Date { 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 { suspend fun getAddon(projectId: Int): Addon {
@ -64,7 +64,7 @@ class CurseClient {
return json.decodeFromString( return json.decodeFromString(
http.post("api/v2/addon") { http.post("api/v2/addon") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
body = projectIds body = json.encodeToString(projectIds)
} }
) )
} }
@ -72,7 +72,7 @@ class CurseClient {
suspend fun getAddons(vararg projectIds: Int): List<Addon> = getAddons(projectIds.toList()) suspend fun getAddons(vararg projectIds: Int): List<Addon> = getAddons(projectIds.toList())
suspend fun getAddonDescription(projectId: Int): String { 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<AddonFile> { suspend fun getAddonFiles(projectId: Int): List<AddonFile> {
@ -83,7 +83,7 @@ class CurseClient {
return json.decodeFromString( return json.decodeFromString(
http.post("api/v2/addon/files") { http.post("api/v2/addon/files") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
body = keys body = json.encodeToString(keys)
} }
) )
} }
@ -91,7 +91,7 @@ class CurseClient {
suspend fun getAddonFiles(vararg keys: Int): Map<Int, List<AddonFile>> = getAddonFiles(keys.toList()) suspend fun getAddonFiles(vararg keys: Int): Map<Int, List<AddonFile>> = getAddonFiles(keys.toList())
suspend fun getAddonFileDownloadUrl(projectId: Int, fileId: Int): String { 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 { suspend fun getAddonFile(projectId: Int, fileId: Int): AddonFile {
@ -106,7 +106,7 @@ class CurseClient {
sortDescending: Boolean = true, sortDescending: Boolean = true,
gameVersion: String? = null, gameVersion: String? = null,
index: Int = 0, index: Int = 0,
pageSize: Int = 1000, pageSize: Int = 10,
searchFilter: String? = null searchFilter: String? = null
): List<Addon> { ): List<Addon> {
return json.decodeFromString( return json.decodeFromString(
@ -134,7 +134,7 @@ class CurseClient {
return json.decodeFromString( return json.decodeFromString(
http.post("api/v2/addon/featured") { http.post("api/v2/addon/featured") {
contentType(ContentType.Application.Json) 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 { 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<Long>): FingerprintMatchResult { suspend fun getFingerprintMatches(fingerprints: Collection<Long>): FingerprintMatchResult {
return json.decodeFromString( return json.decodeFromString(
http.post("api/v2/fingerprint") { http.post("api/v2/fingerprint") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
body = fingerprints body = json.encodeToString(fingerprints)
} }
) )
} }
@ -186,7 +186,7 @@ class CurseClient {
return json.decodeFromString( return json.decodeFromString(
http.post("api/v2/fingerprint/fuzzy") { http.post("api/v2/fingerprint/fuzzy") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
body = FuzzyMatchesRequest(gameId, fingerprints) body = json.encodeToString(FuzzyMatchesRequest(gameId, fingerprints))
} }
) )
} }
@ -208,7 +208,7 @@ class CurseClient {
} }
suspend fun getModloadersDatabaseTimestamp(): Date { 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<MinecraftVersion> { suspend fun getMinecraftVersions(): List<MinecraftVersion> {
@ -220,6 +220,10 @@ class CurseClient {
} }
suspend fun getMinecraftVersionsDatabaseTimestamp(): Date { 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")
} }
} }

View File

@ -13,7 +13,10 @@ object JCurseforge : KotlinPlugin(
version = "0.1.0", version = "0.1.0",
) { ) {
author("jie65535") author("jie65535")
info("""Curseforge Util""") info("""
MC Curseforge Util
https://github.com/jie65535/mirai-console-jcf-plugin
""".trimIndent())
} }
) { ) {
override fun onEnable() { override fun onEnable() {

View File

@ -2,17 +2,52 @@ package me.jie65535.jcf
import net.mamoe.mirai.console.command.CommandSender import net.mamoe.mirai.console.command.CommandSender
import net.mamoe.mirai.console.command.CompositeCommand 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( object JcfCommand : CompositeCommand(
JCurseforge, "jcf", JCurseforge, "jcf",
description = "Curseforge Util" description = "Curseforge Util"
) { ) {
private val curse = CurseClient()
@SubCommand @SubCommand
@Description("帮助") @Description("帮助")
suspend fun CommandSender.help() { 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)
// }
} }

View File

@ -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<Addon>, 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<AddonFile>, 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
}
}

View File

@ -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<MessageEvent>(
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<MessageEvent>(
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<MessageEvent>(
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<Addon> {
return curseClient.searchAddons(
gameId = GAME_ID_MINECRAFT,
sectionId = sectionId,
categoryId = -1,
sort = AddonSortMethod.POPULARITY,
sortDescending = true,
gameVersion = null,
index = pageIndex,
pageSize = pageSize,
searchFilter = filter,
)
}
}

View File

@ -7,14 +7,47 @@
package me.jie65535.jcf.model.addon package me.jie65535.jcf.model.addon
/**
* 附加排序方法
*/
enum class AddonSortMethod { enum class AddonSortMethod {
/**
* 精选
*/
FEATURED, FEATURED,
/**
* 人气
*/
POPULARITY, POPULARITY,
/**
* 最后更新时间
*/
LAST_UPDATED, LAST_UPDATED,
/**
* 名称
*/
NAME, NAME,
/**
* 作者
*/
AUTHOR, AUTHOR,
/**
* 总下载数
*/
TOTAL_DOWNLOADS, TOTAL_DOWNLOADS,
/**
* 类别
*/
CATEGORY, CATEGORY,
/**
* 游戏版本
*/
GAME_VERSION GAME_VERSION
} }

View File

@ -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
}
}