mirror of
https://github.com/jie65535/mirai-console-jcf-plugin.git
synced 2026-05-04 23:23:40 +08:00
Add Modrinth platform integration (#13)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jie65535 <29452349+jie65535@users.noreply.github.com>
This commit is contained in:
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "top.jie65535.jcf"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
|
||||
repositories {
|
||||
maven("https://maven.aliyun.com/repository/public")
|
||||
@@ -19,4 +19,7 @@ dependencies {
|
||||
// implementation("io.ktor:ktor-client-core:$ktorVersion")
|
||||
implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
|
||||
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class MessageHandler(
|
||||
val pagedList = service.search(modClass, filter)
|
||||
with(pagedList.current()) {
|
||||
if (isEmpty()) {
|
||||
subject.sendMessage("未搜索到关键字\"$filter\"相关结果")
|
||||
subject.sendMessage("未搜索到相关结果")
|
||||
} else if (size == 1) {
|
||||
handleShowMod(get(0))
|
||||
} else {
|
||||
|
||||
126
src/main/kotlin/top/jie65535/jcf/ModrinthApi.kt
Normal file
126
src/main/kotlin/top/jie65535/jcf/ModrinthApi.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
package top.jie65535.jcf
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.jie65535.jcf.model.modrinth.Project
|
||||
import top.jie65535.jcf.model.modrinth.SearchResponse
|
||||
import top.jie65535.jcf.model.modrinth.Version
|
||||
|
||||
/**
|
||||
* HTTP client for the Modrinth API. No API key is required for read operations.
|
||||
* [Api docs](https://docs.modrinth.com/api/)
|
||||
* @author jie65535
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class ModrinthApi {
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
private val http = HttpClient(OkHttp) {
|
||||
install(HttpTimeout) {
|
||||
this.requestTimeoutMillis = 60_000
|
||||
this.connectTimeoutMillis = 60_000
|
||||
this.socketTimeoutMillis = 60_000
|
||||
}
|
||||
defaultRequest {
|
||||
url.protocol = URLProtocol.HTTPS
|
||||
url.host = "api.modrinth.com"
|
||||
header("accept", "application/json")
|
||||
// Modrinth recommends a descriptive User-Agent to identify the application.
|
||||
// No API key is required for read-only (search/get) endpoints.
|
||||
header("User-Agent", "jie65535/mirai-console-jcf-plugin (https://github.com/jie65535/mirai-console-jcf-plugin)")
|
||||
}
|
||||
}
|
||||
|
||||
//region - Projects -
|
||||
|
||||
/**
|
||||
* Search for projects.
|
||||
* @param query The search query string.
|
||||
* @param facets JSON-encoded facet filter, e.g. `[["project_type:mod"]]`.
|
||||
* @param index The sorting method (relevance, downloads, follows, newest, updated).
|
||||
* @param offset Number of results to skip (for pagination).
|
||||
* @param limit Maximum number of results to return (max 100).
|
||||
*/
|
||||
suspend fun search(
|
||||
query: String? = null,
|
||||
facets: String? = null,
|
||||
index: String = "relevance",
|
||||
offset: Int = 0,
|
||||
limit: Int = 10,
|
||||
): SearchResponse {
|
||||
val response = http.get("/v2/search") {
|
||||
parameter("query", query)
|
||||
parameter("facets", facets)
|
||||
parameter("index", index)
|
||||
parameter("offset", offset)
|
||||
parameter("limit", limit)
|
||||
}
|
||||
return json.decodeFromString(response.body<String>())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details of a single project by ID or slug.
|
||||
*/
|
||||
suspend fun getProject(idOrSlug: String): Project {
|
||||
val response = http.get("/v2/project/$idOrSlug")
|
||||
return json.decodeFromString(response.body<String>())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple projects at once by their IDs.
|
||||
* @param ids List of project IDs.
|
||||
*/
|
||||
suspend fun getProjects(ids: List<String>): List<Project> {
|
||||
val response = http.get("/v2/projects") {
|
||||
parameter("ids", json.encodeToString(ListSerializer(String.serializer()), ids))
|
||||
}
|
||||
return json.decodeFromString(response.body<String>())
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region - Versions -
|
||||
|
||||
/**
|
||||
* List all versions of a project.
|
||||
* @param idOrSlug Project ID or slug.
|
||||
* @param loaders Filter by mod loader(s).
|
||||
* @param gameVersions Filter by Minecraft version(s).
|
||||
* @param featured When true, only return featured versions.
|
||||
*/
|
||||
suspend fun getProjectVersions(
|
||||
idOrSlug: String,
|
||||
loaders: List<String>? = null,
|
||||
gameVersions: List<String>? = null,
|
||||
featured: Boolean? = null,
|
||||
): List<Version> {
|
||||
val response = http.get("/v2/project/$idOrSlug/version") {
|
||||
loaders?.let { parameter("loaders", json.encodeToString(ListSerializer(String.serializer()), it)) }
|
||||
gameVersions?.let { parameter("game_versions", json.encodeToString(ListSerializer(String.serializer()), it)) }
|
||||
parameter("featured", featured)
|
||||
}
|
||||
return json.decodeFromString(response.body<String>())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details of a single version by ID.
|
||||
*/
|
||||
suspend fun getVersion(versionId: String): Version {
|
||||
val response = http.get("/v2/version/$versionId")
|
||||
return json.decodeFromString(response.body<String>())
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
266
src/main/kotlin/top/jie65535/jcf/ModrinthMessageHandler.kt
Normal file
266
src/main/kotlin/top/jie65535/jcf/ModrinthMessageHandler.kt
Normal file
@@ -0,0 +1,266 @@
|
||||
package top.jie65535.jcf
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import net.mamoe.mirai.contact.nameCardOrNick
|
||||
import net.mamoe.mirai.event.*
|
||||
import net.mamoe.mirai.event.events.GroupMessageEvent
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import net.mamoe.mirai.message.data.*
|
||||
import net.mamoe.mirai.message.data.MessageSource.Key.quote
|
||||
import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
|
||||
import net.mamoe.mirai.utils.MiraiLogger
|
||||
import top.jie65535.jcf.MessageHandler.Companion.HTMLPattern
|
||||
import top.jie65535.jcf.MessageHandler.Companion.sendLargeMessage
|
||||
import top.jie65535.jcf.model.modrinth.SearchHit
|
||||
import top.jie65535.jcf.model.modrinth.Version
|
||||
import top.jie65535.jcf.util.PagedList
|
||||
import top.jie65535.jcf.util.HttpUtil
|
||||
import java.text.DecimalFormat
|
||||
|
||||
/**
|
||||
* Handles QQ message events for Modrinth searches and project browsing.
|
||||
*/
|
||||
class ModrinthMessageHandler(
|
||||
private val service: ModrinthService,
|
||||
private val subsHandler: ModrinthSubscribeHandler,
|
||||
private val eventChannel: EventChannel<Event>,
|
||||
private val logger: MiraiLogger,
|
||||
) {
|
||||
|
||||
fun startListen() {
|
||||
eventChannel.subscribeMessages {
|
||||
for ((projectType, command) in PluginConfig.mrSearchCommands) {
|
||||
if (command.isBlank()) continue
|
||||
startsWith(command) {
|
||||
val filter = it.trim()
|
||||
if (filter.isEmpty()) {
|
||||
subject.sendMessage(message.quote() + "必须输入关键字")
|
||||
} else {
|
||||
try {
|
||||
logger.info("${sender.nameCardOrNick}(${sender.id}) Modrinth $projectType \"$filter\"")
|
||||
val pagedList = service.search(projectType, filter)
|
||||
with(pagedList.current()) {
|
||||
if (isEmpty()) {
|
||||
subject.sendMessage("未搜索到相关结果")
|
||||
} else if (size == 1) {
|
||||
handleShowProject(get(0))
|
||||
} else {
|
||||
handleSearchResult(pagedList)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
subject.sendMessage(message.quote() + "发生内部错误,请稍后重试")
|
||||
logger.error("消息\"$message\"引发异常", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic paged-list interaction: show items, wait for user selection, return selected item.
|
||||
*/
|
||||
private suspend fun <T> MessageEvent.handlePagedList(
|
||||
pagedList: PagedList<T>,
|
||||
format: suspend (T) -> Message
|
||||
): T? {
|
||||
do {
|
||||
var isContinue = false
|
||||
val list = pagedList.current()
|
||||
val listMessage = subject.sendMessage(buildForwardMessage {
|
||||
for ((i, it) in list.withIndex()) {
|
||||
bot.id named i.toString() says format(it)
|
||||
}
|
||||
var msg = "$WAIT_REPLY_TIMEOUT_S 秒内回复编号查看"
|
||||
if (pagedList.hasPrev) msg += "\n回复 $PAGE_UP_KEYWORD 上一页"
|
||||
if (pagedList.hasNext) msg += "\n回复 $PAGE_DOWN_KEYWORD 下一页"
|
||||
bot says msg
|
||||
})
|
||||
try {
|
||||
val next = withTimeout(WAIT_REPLY_TIMEOUT_S * 1000L) {
|
||||
eventChannel.nextEvent<MessageEvent>(EventPriority.MONITOR) { it.sender == sender }
|
||||
}
|
||||
val nextMessage = next.message.content
|
||||
if (nextMessage.equals(PAGE_DOWN_KEYWORD, true)) {
|
||||
pagedList.next()
|
||||
} else if (nextMessage.equals(PAGE_UP_KEYWORD, true)) {
|
||||
pagedList.prev()
|
||||
} else {
|
||||
return list[nextMessage.toInt()]
|
||||
}
|
||||
isContinue = true
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
subject.sendMessage("等待回复超时,请重新查询。")
|
||||
} catch (e: NumberFormatException) {
|
||||
subject.sendMessage("请回复正确的选项,此次查询已取消,请重新查询。")
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
subject.sendMessage("请回复正确的序号,此次查询已取消,请重新查询。")
|
||||
} catch (e: Throwable) {
|
||||
subject.sendMessage(message.quote() + "发生内部错误,请稍后重试")
|
||||
logger.warning("回复消息\"$message\"引发意外的异常", e)
|
||||
} finally {
|
||||
listMessage.recall()
|
||||
}
|
||||
} while (isContinue)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a paged list of search hits and handle selection.
|
||||
*/
|
||||
private suspend fun MessageEvent.handleSearchResult(pagedList: PagedList<SearchHit>) {
|
||||
val selected = handlePagedList(pagedList) { hit ->
|
||||
val text = PlainText(
|
||||
"""
|
||||
[${hit.title}] by ${hit.author}
|
||||
${formatCount(hit.downloads)} Downloads Updated ${hit.dateModified.take(10)}
|
||||
${hit.description}
|
||||
https://modrinth.com/${hit.projectType}/${hit.slug}
|
||||
""".trimIndent()
|
||||
)
|
||||
hit.iconUrl?.let { loadImage(it) + text } ?: text
|
||||
}
|
||||
selected?.let { handleShowProject(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Show detailed information for a project (from a search hit).
|
||||
*/
|
||||
private suspend fun MessageEvent.handleShowProject(hit: SearchHit) {
|
||||
// Fetch full project details
|
||||
val project = try {
|
||||
service.getProject(hit.projectId)
|
||||
} catch (e: Throwable) {
|
||||
logger.warning("获取 Modrinth 项目[${hit.projectId}]详情时异常", e)
|
||||
subject.sendMessage("获取项目详情时异常,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch latest versions (up to DEFAULT_SHOW_VERSIONS)
|
||||
val versions = try {
|
||||
service.getProjectVersions(project.id).current()
|
||||
} catch (e: Throwable) {
|
||||
logger.warning("获取 Modrinth 项目[${project.id}]版本列表时异常", e)
|
||||
emptyArray()
|
||||
}
|
||||
|
||||
subject.sendMessage(buildForwardMessage {
|
||||
// Icon
|
||||
project.iconUrl?.let {
|
||||
if (it.isNotBlank()) bot says loadImage(it)
|
||||
}
|
||||
// Basic info
|
||||
bot says PlainText(with(project) {
|
||||
"""
|
||||
$title
|
||||
作者:${hit.author}
|
||||
概括:$description
|
||||
项目ID:$id
|
||||
发布时间:${published.take(10)}
|
||||
最近更新:${updated.take(10)}
|
||||
总下载:$downloads
|
||||
主页:https://modrinth.com/$projectType/$slug
|
||||
""".trimIndent()
|
||||
})
|
||||
|
||||
var msg = "$WAIT_REPLY_TIMEOUT_S 秒内回复 $SUBSCRIBE_KEYWORD 订阅项目更新"
|
||||
if (versions.isNotEmpty()) {
|
||||
msg += "\n回复编号查看版本详细信息\n" +
|
||||
"回复 $VIEW_VERSIONS_KEYWORD 查看全部历史版本"
|
||||
}
|
||||
bot says msg
|
||||
|
||||
for ((i, ver) in versions.withIndex()) {
|
||||
bot.id named i.toString() says PlainText(
|
||||
"""
|
||||
${ver.name} [${ver.versionType}] [${ver.datePublished.take(10)}]
|
||||
支持版本:${ver.gameVersions.joinToString()}
|
||||
加载器:${ver.loaders.joinToString()}
|
||||
${ver.files.firstOrNull { it.primary }?.url ?: ver.files.firstOrNull()?.url ?: ""}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
val next = withTimeout(WAIT_REPLY_TIMEOUT_S * 1000L) {
|
||||
eventChannel.nextEvent<MessageEvent>(EventPriority.MONITOR) { it.sender == sender }
|
||||
}
|
||||
val nextMessage = next.message.content
|
||||
if (nextMessage.equals(SUBSCRIBE_KEYWORD, true)) {
|
||||
val latestVersionId = project.versions.firstOrNull()
|
||||
if (next is GroupMessageEvent) {
|
||||
subsHandler.sub(project.id, latestVersionId, next.sender.id, next.group.id)
|
||||
} else {
|
||||
subsHandler.sub(project.id, latestVersionId, next.sender.id)
|
||||
}
|
||||
subject.sendMessage(QuoteReply(next.source) + "已添加 Modrinth 订阅")
|
||||
} else if (versions.isNotEmpty()) {
|
||||
if (nextMessage.equals(VIEW_VERSIONS_KEYWORD, true)) {
|
||||
// Show all versions
|
||||
handleVersionList(service.getProjectVersions(project.id))
|
||||
} else {
|
||||
// Show selected version's changelog
|
||||
handleVersion(versions[nextMessage.toInt()])
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// Silently ignore timeout, bad index, etc.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a paged list of versions and handle selection.
|
||||
*/
|
||||
private suspend fun MessageEvent.handleVersionList(pagedList: PagedList<Version>) {
|
||||
val selected = handlePagedList(pagedList) { ver ->
|
||||
PlainText(
|
||||
"""
|
||||
${ver.name} [${ver.versionType}] [${ver.datePublished.take(10)}]
|
||||
支持版本:${ver.gameVersions.joinToString()}
|
||||
加载器:${ver.loaders.joinToString()}
|
||||
${ver.files.firstOrNull { it.primary }?.url ?: ver.files.firstOrNull()?.url ?: ""}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
selected?.let { handleVersion(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the changelog for a version.
|
||||
*/
|
||||
private suspend fun MessageEvent.handleVersion(version: Version) {
|
||||
val changelog = version.changelog?.replace(Regex("\n+"), "\n") ?: "暂无更新日志"
|
||||
subject.sendMessage(sendLargeMessage(changelog))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WAIT_REPLY_TIMEOUT_S = 60
|
||||
private const val PAGE_UP_KEYWORD = "P"
|
||||
private const val PAGE_DOWN_KEYWORD = "N"
|
||||
private const val VIEW_VERSIONS_KEYWORD = "ALL"
|
||||
private const val SUBSCRIBE_KEYWORD = "订阅"
|
||||
private val singleDecimalFormat = DecimalFormat("0.#")
|
||||
|
||||
private fun formatCount(count: Long): String = when {
|
||||
count < 1_000L -> count.toString()
|
||||
count < 1_000_000L -> singleDecimalFormat.format(count / 1_000.0) + "K"
|
||||
count < 1_000_000_000L -> singleDecimalFormat.format(count / 1_000_000.0) + "M"
|
||||
else -> count.toString()
|
||||
}
|
||||
|
||||
private suspend fun MessageEvent.loadImage(url: String): Image {
|
||||
val imgFileName = url.substringAfterLast("/").substringBefore("?")
|
||||
val file = PluginMain.resolveDataFile("cache/$imgFileName")
|
||||
val res = if (file.exists()) {
|
||||
file.readBytes().toExternalResource()
|
||||
} else {
|
||||
HttpUtil.downloadImage(url, file).toExternalResource()
|
||||
}
|
||||
val image = subject.uploadImage(res)
|
||||
withContext(Dispatchers.IO) { res.close() }
|
||||
return image
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/main/kotlin/top/jie65535/jcf/ModrinthService.kt
Normal file
86
src/main/kotlin/top/jie65535/jcf/ModrinthService.kt
Normal file
@@ -0,0 +1,86 @@
|
||||
package top.jie65535.jcf
|
||||
|
||||
import top.jie65535.jcf.model.modrinth.Project
|
||||
import top.jie65535.jcf.model.modrinth.SearchHit
|
||||
import top.jie65535.jcf.model.modrinth.Version
|
||||
import top.jie65535.jcf.util.PagedList
|
||||
|
||||
class ModrinthService {
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_PAGE_SIZE = 10
|
||||
}
|
||||
|
||||
/**
|
||||
* Modrinth project types.
|
||||
*/
|
||||
enum class ProjectType(val typeName: String, val typeId: String) {
|
||||
/** 模组 */
|
||||
MODS("模组", "mod"),
|
||||
|
||||
/** 整合包 */
|
||||
MODPACKS("整合包", "modpack"),
|
||||
|
||||
/** 资源包 */
|
||||
RESOURCE_PACKS("资源包", "resourcepack"),
|
||||
|
||||
/** 光影 */
|
||||
SHADERS("光影", "shader"),
|
||||
|
||||
/** 服务器插件 */
|
||||
PLUGINS("服务器插件", "plugin"),
|
||||
|
||||
/** 数据包 */
|
||||
DATA_PACKS("数据包", "datapack"),
|
||||
}
|
||||
|
||||
private val api = ModrinthApi()
|
||||
|
||||
/**
|
||||
* Search Modrinth projects by type and filter string.
|
||||
* @param projectType The type of project to search for.
|
||||
* @param filter The search query.
|
||||
* @return A paged list of search hits.
|
||||
*/
|
||||
fun search(projectType: ProjectType, filter: String): PagedList<SearchHit> =
|
||||
PagedList(DEFAULT_PAGE_SIZE) { offset ->
|
||||
val facets = """[["project_type:${projectType.typeId}"]]"""
|
||||
val response = api.search(
|
||||
query = filter,
|
||||
facets = facets,
|
||||
offset = offset,
|
||||
limit = DEFAULT_PAGE_SIZE,
|
||||
)
|
||||
response.hits.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full details for a single project.
|
||||
*/
|
||||
suspend fun getProject(idOrSlug: String): Project = api.getProject(idOrSlug)
|
||||
|
||||
/**
|
||||
* Fetch all versions of a project and return them as a paged list.
|
||||
*/
|
||||
suspend fun getProjectVersions(idOrSlug: String): PagedList<Version> {
|
||||
val allVersions = api.getProjectVersions(idOrSlug)
|
||||
return PagedList(DEFAULT_PAGE_SIZE) { offset ->
|
||||
if (offset >= allVersions.size) {
|
||||
emptyArray()
|
||||
} else {
|
||||
val end = minOf(offset + DEFAULT_PAGE_SIZE, allVersions.size)
|
||||
allVersions.subList(offset, end).toTypedArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple projects at once.
|
||||
*/
|
||||
suspend fun getProjects(ids: List<String>): List<Project> = api.getProjects(ids)
|
||||
|
||||
/**
|
||||
* Fetch a single version by ID.
|
||||
*/
|
||||
suspend fun getVersion(versionId: String): Version = api.getVersion(versionId)
|
||||
}
|
||||
272
src/main/kotlin/top/jie65535/jcf/ModrinthSubscribeHandler.kt
Normal file
272
src/main/kotlin/top/jie65535/jcf/ModrinthSubscribeHandler.kt
Normal file
@@ -0,0 +1,272 @@
|
||||
package top.jie65535.jcf
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.mamoe.mirai.Bot
|
||||
import net.mamoe.mirai.message.data.At
|
||||
import net.mamoe.mirai.message.data.buildMessageChain
|
||||
import net.mamoe.mirai.utils.MiraiLogger
|
||||
import top.jie65535.jcf.model.modrinth.Project
|
||||
|
||||
/**
|
||||
* Handles Modrinth project update subscriptions and push notifications.
|
||||
*
|
||||
* @param service The Modrinth service.
|
||||
* @param logger Logger instance.
|
||||
*/
|
||||
class ModrinthSubscribeHandler(
|
||||
private val service: ModrinthService,
|
||||
private val logger: MiraiLogger,
|
||||
) {
|
||||
|
||||
// region -- Subscription management
|
||||
|
||||
/**
|
||||
* Clear subscriptions.
|
||||
*
|
||||
* @param projectId Project ID; null clears all subscriptions.
|
||||
* @param group Group ID; null removes all subscriptions for the project.
|
||||
*/
|
||||
fun clean(projectId: String? = null, group: Long? = null) {
|
||||
var updInner = false
|
||||
val projectSet = HashMap(PluginData.mrProjectsLastVersion)
|
||||
val subSet = HashMap(PluginData.mrSubscriptionSet)
|
||||
if (projectId == null) {
|
||||
subSet.clear()
|
||||
projectSet.clear()
|
||||
logger.info("清理所有 Modrinth 订阅")
|
||||
} else if (group == null) {
|
||||
projectSet -= projectId
|
||||
subSet -= projectId
|
||||
logger.info("清理 Modrinth 项目[$projectId]的订阅")
|
||||
} else {
|
||||
subSet[projectId]?.let {
|
||||
logger.info("清理 Modrinth 群/分组[$group]的订阅")
|
||||
updInner = true
|
||||
it -= group
|
||||
}
|
||||
}
|
||||
|
||||
if (projectSet.size != PluginData.mrProjectsLastVersion.size) {
|
||||
PluginData.mrProjectsLastVersion = projectSet
|
||||
}
|
||||
if (subSet.size != PluginData.mrSubscriptionSet.size || updInner) {
|
||||
PluginData.mrSubscriptionSet = subSet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription.
|
||||
*
|
||||
* @param projectId Project ID.
|
||||
* @param qq The subscriber's QQ number.
|
||||
* @param group Group ID; null means a personal subscription.
|
||||
*/
|
||||
fun unsub(projectId: String, qq: Long, group: Long? = null) {
|
||||
val gid = group ?: GROUP_ID_SINGLE
|
||||
val subSet = HashMap(PluginData.mrSubscriptionSet)
|
||||
val groups = subSet[projectId] ?: return
|
||||
val members = groups[gid] ?: return
|
||||
members -= qq
|
||||
PluginData.mrSubscriptionSet = subSet
|
||||
logger.info("取消 Modrinth 订阅--{$projectId:{$gid:[$qq]}}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a subscription.
|
||||
*
|
||||
* @param projectId Project ID.
|
||||
* @param latestVersionId The currently latest version ID (used to track updates).
|
||||
* @param qq The subscriber's QQ number.
|
||||
* @param group Group ID; null means a personal subscription.
|
||||
*/
|
||||
fun sub(projectId: String, latestVersionId: String?, qq: Long, group: Long? = null) {
|
||||
val gid = group ?: GROUP_ID_SINGLE
|
||||
val projectSet = HashMap(PluginData.mrProjectsLastVersion)
|
||||
val subSet = HashMap(PluginData.mrSubscriptionSet)
|
||||
if (projectId !in projectSet) projectSet[projectId] = latestVersionId ?: ""
|
||||
|
||||
val groupSet = subSet[projectId] ?: mutableMapOf()
|
||||
val qqSet = groupSet[gid] ?: mutableListOf()
|
||||
var changed = gid !in groupSet
|
||||
subSet[projectId] = groupSet
|
||||
groupSet[gid] = qqSet
|
||||
if (qq !in qqSet) {
|
||||
qqSet += qq
|
||||
changed = true
|
||||
logger.info("添加 Modrinth 订阅--{$projectId:{$gid:[$qq]}}")
|
||||
}
|
||||
|
||||
if (projectId !in PluginData.mrProjectsLastVersion) {
|
||||
PluginData.mrProjectsLastVersion = projectSet
|
||||
}
|
||||
if (changed) {
|
||||
PluginData.mrSubscriptionSet = subSet
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region -- Update checking
|
||||
|
||||
/**
|
||||
* Check all subscribed projects for updates.
|
||||
*
|
||||
* @param init When true, emit all projects even when unchanged (for initialization).
|
||||
* @return Flow of (projectId, latestVersionId) pairs; latestVersionId is empty if the project was not found.
|
||||
*/
|
||||
private suspend fun checkUpdate(init: Boolean = false) = flow {
|
||||
val oldSet = PluginData.mrProjectsLastVersion
|
||||
if (oldSet.isNotEmpty()) {
|
||||
val fetchProjects = service.getProjects(oldSet.keys.toList())
|
||||
.associateBy { it.id }
|
||||
for ((projectId, oldVersionId) in oldSet) {
|
||||
try {
|
||||
val project = fetchProjects[projectId]
|
||||
if (project == null) {
|
||||
emit(projectId to "")
|
||||
continue
|
||||
}
|
||||
PROJECT_INFO_CACHE[projectId] = project
|
||||
val latestVersionId = project.versions.firstOrNull() ?: ""
|
||||
if (oldVersionId != latestVersionId || init) {
|
||||
emit(projectId to latestVersionId)
|
||||
if (!init) logger.info("Modrinth 项目更新【${project.title}】")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warning("Modrinth 检查更新异常: ${e.message}")
|
||||
emit(projectId to "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the changelog for a version.
|
||||
*/
|
||||
private suspend fun getChangelog(versionId: String): String = try {
|
||||
val version = service.getVersion(versionId)
|
||||
version.changelog?.replace(Regex("\n+"), "\n") ?: ""
|
||||
} catch (e: Exception) {
|
||||
logger.warning("Modrinth 获取版本[$versionId]更新日志异常: ${e.message}")
|
||||
""
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update notifications to all subscribers of a project.
|
||||
*/
|
||||
private suspend fun send(sender: Bot, projectId: String, changelog: String) {
|
||||
if (changelog.isBlank()) return
|
||||
val subGroups = PluginData.mrSubscriptionSet[projectId] ?: return
|
||||
val projectTitle = PROJECT_INFO_CACHE[projectId]?.title ?: return
|
||||
val title = "你订阅的 Modrinth 项目【$projectTitle】更新啦!"
|
||||
val context = "更新日志:\n$changelog"
|
||||
subGroups.forEach { (group, qqs) ->
|
||||
if (group == GROUP_ID_SINGLE) {
|
||||
qqs.forEach {
|
||||
sender.getFriend(it)?.apply {
|
||||
sendMessage(title)
|
||||
sendMessage(context)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sender.getGroup(group)?.apply {
|
||||
val titleChain = buildMessageChain {
|
||||
qqs.forEach { +At(it) }
|
||||
+"\n$title"
|
||||
}
|
||||
sendMessage(titleChain)
|
||||
sendMessage(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single update: fetch changelog and push notifications.
|
||||
*/
|
||||
private suspend fun feedback(senderQQ: Long, projectId: String, versionId: String) {
|
||||
if (versionId.isBlank()) return
|
||||
Bot.instances.firstOrNull { it.isOnline && it.id == senderQQ }?.let { sender ->
|
||||
val changelog = getChangelog(versionId)
|
||||
send(sender, projectId, changelog)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main subscription check loop.
|
||||
*/
|
||||
private fun CoroutineScope.loop() = launch {
|
||||
val senderQQ = PluginConfig.subscribeSender
|
||||
if (senderQQ < 0) {
|
||||
logger.warning("Modrinth 订阅:未配置推送 bot,将收集订阅但无法推送消息。")
|
||||
}
|
||||
logger.info("Modrinth 订阅监听已启动")
|
||||
while (true) {
|
||||
delay(1000 * PluginConfig.checkInterval)
|
||||
if (isIdle) continue
|
||||
|
||||
val subSet = HashMap(PluginData.mrSubscriptionSet)
|
||||
val projectVersions = HashMap(PluginData.mrProjectsLastVersion)
|
||||
checkUpdate()
|
||||
.buffer()
|
||||
.collect { (projectId, versionId) ->
|
||||
if (versionId.isBlank()) {
|
||||
projectVersions -= projectId
|
||||
subSet -= projectId
|
||||
} else {
|
||||
projectVersions[projectId] = versionId
|
||||
feedback(senderQQ, projectId, versionId)
|
||||
}
|
||||
}
|
||||
PluginData.mrSubscriptionSet = subSet
|
||||
PluginData.mrProjectsLastVersion = projectVersions
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region -- State
|
||||
|
||||
/** Whether the handler is currently idle (not checking for updates). */
|
||||
var isIdle = true
|
||||
private set
|
||||
|
||||
fun start() { isIdle = false }
|
||||
fun idle() { isIdle = true }
|
||||
|
||||
/**
|
||||
* Initialize the handler: sync latest version IDs and start the update loop.
|
||||
*/
|
||||
suspend fun load(scope: CoroutineScope) {
|
||||
logger.info("Modrinth 订阅:初始化中...")
|
||||
val subs = HashMap(PluginData.mrSubscriptionSet)
|
||||
val versions = HashMap(PluginData.mrProjectsLastVersion)
|
||||
checkUpdate(true)
|
||||
.buffer()
|
||||
.collect { (projectId, versionId) ->
|
||||
if (versionId.isBlank()) {
|
||||
versions -= projectId
|
||||
subs -= projectId
|
||||
} else {
|
||||
versions[projectId] = versionId
|
||||
}
|
||||
}
|
||||
PluginData.mrSubscriptionSet = subs
|
||||
PluginData.mrProjectsLastVersion = versions
|
||||
scope.loop()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
companion object {
|
||||
/** Marker for personal (non-group) subscriptions. */
|
||||
const val GROUP_ID_SINGLE: Long = 0
|
||||
|
||||
/** Cache of recently seen project details. */
|
||||
private val PROJECT_INFO_CACHE: MutableMap<String, Project> = mutableMapOf()
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,14 @@ object PluginCommands : CompositeCommand(PluginMain, "jcf") {
|
||||
@Description("查看插件帮助")
|
||||
suspend fun CommandSender.help() {
|
||||
val msg = StringBuilder()
|
||||
msg.appendLine("=== CurseForge 命令 ===")
|
||||
for ((modClass, cmd) in PluginConfig.searchCommands) {
|
||||
msg.appendLine("搜索${modClass.className}: $cmd")
|
||||
}
|
||||
msg.appendLine("=== Modrinth 命令 ===")
|
||||
for ((projectType, cmd) in PluginConfig.mrSearchCommands) {
|
||||
msg.appendLine("搜索${projectType.typeName}: $cmd")
|
||||
}
|
||||
sendMessage(msg.toString())
|
||||
}
|
||||
|
||||
@@ -36,27 +41,64 @@ object PluginCommands : CompositeCommand(PluginMain, "jcf") {
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
@Description("查看订阅处理的状态")
|
||||
@Description("查看 CurseForge 订阅处理的状态")
|
||||
suspend fun CommandSender.subStat() {
|
||||
if (!PluginMain.isCurseForgeEnabled) {
|
||||
sendMessage("CurseForge 未配置 API Key,订阅功能不可用")
|
||||
return
|
||||
}
|
||||
val subs = PluginMain.subscribeHandler
|
||||
if (subs.isIdle) {
|
||||
sendMessage("订阅器闲置中")
|
||||
sendMessage("CurseForge 订阅器闲置中")
|
||||
} else {
|
||||
sendMessage("订阅处理正常运行中")
|
||||
sendMessage("CurseForge 订阅处理正常运行中")
|
||||
}
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
@Description("使订阅器闲置")
|
||||
@Description("使 CurseForge 订阅器闲置")
|
||||
suspend fun CommandSender.idleSubs() {
|
||||
if (!PluginMain.isCurseForgeEnabled) {
|
||||
sendMessage("CurseForge 未配置 API Key,订阅功能不可用")
|
||||
return
|
||||
}
|
||||
PluginMain.subscribeHandler.idle()
|
||||
sendMessage("OK,已闲置")
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
@Description("使订阅器恢复运行")
|
||||
@Description("使 CurseForge 订阅器恢复运行")
|
||||
suspend fun CommandSender.runSubs() {
|
||||
if (!PluginMain.isCurseForgeEnabled) {
|
||||
sendMessage("CurseForge 未配置 API Key,订阅功能不可用")
|
||||
return
|
||||
}
|
||||
PluginMain.subscribeHandler.start()
|
||||
sendMessage("OK,已恢复订阅处理")
|
||||
sendMessage("OK,已恢复 CurseForge 订阅处理")
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
@Description("查看 Modrinth 订阅处理的状态")
|
||||
suspend fun CommandSender.mrSubStat() {
|
||||
val subs = PluginMain.modrinthSubscribeHandler
|
||||
if (subs.isIdle) {
|
||||
sendMessage("Modrinth 订阅器闲置中")
|
||||
} else {
|
||||
sendMessage("Modrinth 订阅处理正常运行中")
|
||||
}
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
@Description("使 Modrinth 订阅器闲置")
|
||||
suspend fun CommandSender.mrIdleSubs() {
|
||||
PluginMain.modrinthSubscribeHandler.idle()
|
||||
sendMessage("OK,Modrinth 订阅器已闲置")
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
@Description("使 Modrinth 订阅器恢复运行")
|
||||
suspend fun CommandSender.mrRunSubs() {
|
||||
PluginMain.modrinthSubscribeHandler.start()
|
||||
sendMessage("OK,已恢复 Modrinth 订阅处理")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,18 @@ object PluginConfig : AutoSavePluginConfig("JCurseforgeConfig") {
|
||||
)
|
||||
)
|
||||
|
||||
@ValueDescription("Modrinth 搜索命令 (MODS,MODPACKS,RESOURCE_PACKS,SHADERS,PLUGINS,DATA_PACKS)")
|
||||
val mrSearchCommands: MutableMap<ModrinthService.ProjectType, String> by value(
|
||||
mutableMapOf(
|
||||
ModrinthService.ProjectType.MODS to "mrmod ",
|
||||
ModrinthService.ProjectType.MODPACKS to "mrpack ",
|
||||
ModrinthService.ProjectType.RESOURCE_PACKS to "mrres ",
|
||||
ModrinthService.ProjectType.SHADERS to "mrshader ",
|
||||
ModrinthService.ProjectType.PLUGINS to "mrplugin ",
|
||||
ModrinthService.ProjectType.DATA_PACKS to "mrdata ",
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 订阅信息推送bot
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ import net.mamoe.mirai.console.data.ValueDescription
|
||||
object PluginData : AutoSavePluginData("JCurseforgeData") {
|
||||
|
||||
/**
|
||||
* 模组最新文件的id集合
|
||||
* 模组最新文件的id集合 (CurseForge)
|
||||
* ```json
|
||||
* {
|
||||
* mod_id: file_id,
|
||||
@@ -19,7 +19,7 @@ object PluginData : AutoSavePluginData("JCurseforgeData") {
|
||||
var modsLastFile: MutableMap<Int, Int> by value()
|
||||
|
||||
/**
|
||||
* 订阅记录
|
||||
* 订阅记录 (CurseForge)
|
||||
* ```json
|
||||
* {
|
||||
* mod_id: {
|
||||
@@ -33,4 +33,32 @@ object PluginData : AutoSavePluginData("JCurseforgeData") {
|
||||
*/
|
||||
@ValueDescription("订阅记录")
|
||||
var subscriptionSet: MutableMap<Int, MutableMap<Long, MutableList<Long>>> by value()
|
||||
|
||||
/**
|
||||
* Modrinth 项目最新版本id集合
|
||||
* ```json
|
||||
* {
|
||||
* project_id: version_id,
|
||||
* ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@ValueDescription("Modrinth 项目最新版本id集合")
|
||||
var mrProjectsLastVersion: MutableMap<String, String> by value()
|
||||
|
||||
/**
|
||||
* Modrinth 订阅记录
|
||||
* ```json
|
||||
* {
|
||||
* project_id: {
|
||||
* group_id: [ qq_id, ... ],
|
||||
* ...
|
||||
* },
|
||||
* ...
|
||||
* }
|
||||
* ```
|
||||
* 个人订阅时,group为0
|
||||
*/
|
||||
@ValueDescription("Modrinth 订阅记录")
|
||||
var mrSubscriptionSet: MutableMap<String, MutableMap<Long, MutableList<Long>>> by value()
|
||||
}
|
||||
|
||||
@@ -11,39 +11,64 @@ object PluginMain: KotlinPlugin(
|
||||
JvmPluginDescription(
|
||||
id = "top.jie65535.jcf",
|
||||
name = "J Curseforge Util",
|
||||
version = "1.1.0",
|
||||
version = "1.2.0",
|
||||
) {
|
||||
author("jie65535")
|
||||
info("MC Curseforge Util\n" +
|
||||
info("MC Curseforge & Modrinth Util\n" +
|
||||
"https://github.com/jie65535/mirai-console-jcf-plugin")
|
||||
}
|
||||
) {
|
||||
/**
|
||||
* 订阅处理类
|
||||
* CurseForge 订阅处理类
|
||||
*/
|
||||
lateinit var subscribeHandler: SubscribeHandler private set
|
||||
|
||||
/**
|
||||
* Modrinth 订阅处理类
|
||||
*/
|
||||
lateinit var modrinthSubscribeHandler: ModrinthSubscribeHandler private set
|
||||
|
||||
/**
|
||||
* CurseForge is enabled only when an API key is configured.
|
||||
*/
|
||||
var isCurseForgeEnabled = false
|
||||
private set
|
||||
|
||||
override fun onEnable() {
|
||||
logger.info { "Plugin loaded" }
|
||||
PluginData.reload()
|
||||
PluginConfig.reload()
|
||||
PluginCommands.register()
|
||||
|
||||
val eventChannel = GlobalEventChannel.parentScope(this)
|
||||
|
||||
// Initialize Modrinth (no API key required)
|
||||
val modrinthService = ModrinthService()
|
||||
modrinthSubscribeHandler = ModrinthSubscribeHandler(modrinthService, logger)
|
||||
val modrinthMessageHandler = ModrinthMessageHandler(modrinthService, modrinthSubscribeHandler, eventChannel, logger)
|
||||
modrinthMessageHandler.startListen()
|
||||
launch {
|
||||
modrinthSubscribeHandler.load(this)
|
||||
}
|
||||
modrinthSubscribeHandler.start()
|
||||
|
||||
// Initialize CurseForge (requires API key)
|
||||
if (PluginConfig.apiKey.isBlank()) {
|
||||
logger.error("必须配置 Curseforge Api Key 才可以使用本插件!\n" +
|
||||
logger.error("未配置 Curseforge Api Key,CurseForge 相关功能不可用!\n" +
|
||||
"请使用 /jcf setApiKey <apiKey> 命令来设置key\n" +
|
||||
"Api key 可以在开发者控制台生成:https://console.curseforge.com/")
|
||||
return
|
||||
} else {
|
||||
val service = MinecraftService(PluginConfig.apiKey)
|
||||
val messageHandler = MessageHandler(service, eventChannel, logger)
|
||||
subscribeHandler = SubscribeHandler(service, logger)
|
||||
messageHandler.startListen()
|
||||
launch {
|
||||
subscribeHandler.load(this)
|
||||
}
|
||||
subscribeHandler.start()
|
||||
isCurseForgeEnabled = true
|
||||
}
|
||||
val service = MinecraftService(PluginConfig.apiKey)
|
||||
val eventChannel = GlobalEventChannel.parentScope(this)
|
||||
val messageHandler = MessageHandler(service, eventChannel, logger)
|
||||
subscribeHandler = SubscribeHandler(service, logger)
|
||||
messageHandler.startListen()
|
||||
launch {
|
||||
subscribeHandler.load(this)
|
||||
}
|
||||
subscribeHandler.start()
|
||||
|
||||
logger.info { "Plugin Enabled" }
|
||||
}
|
||||
}
|
||||
|
||||
92
src/main/kotlin/top/jie65535/jcf/model/modrinth/Project.kt
Normal file
92
src/main/kotlin/top/jie65535/jcf/model/modrinth/Project.kt
Normal file
@@ -0,0 +1,92 @@
|
||||
package top.jie65535.jcf.model.modrinth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Full details of a Modrinth project.
|
||||
* See: https://docs.modrinth.com/api/#tag/projects/operation/getProject
|
||||
*/
|
||||
@Serializable
|
||||
data class Project(
|
||||
/** The project's ID */
|
||||
val id: String,
|
||||
|
||||
/** The project's URL slug */
|
||||
val slug: String,
|
||||
|
||||
/** The project type (mod, modpack, resourcepack, shader, plugin, datapack) */
|
||||
@SerialName("project_type")
|
||||
val projectType: String,
|
||||
|
||||
/** The team that manages the project */
|
||||
val team: String? = null,
|
||||
|
||||
/** The project's display title */
|
||||
val title: String,
|
||||
|
||||
/** A short description of the project */
|
||||
val description: String,
|
||||
|
||||
/** Long-form description in Markdown */
|
||||
val body: String? = null,
|
||||
|
||||
/** The publication date (ISO 8601) */
|
||||
val published: String,
|
||||
|
||||
/** The last update date (ISO 8601) */
|
||||
val updated: String,
|
||||
|
||||
/** Project status (approved, archived, rejected, etc.) */
|
||||
val status: String? = null,
|
||||
|
||||
/** Total number of downloads */
|
||||
val downloads: Long,
|
||||
|
||||
/** Total number of followers */
|
||||
val followers: Int,
|
||||
|
||||
/** Primary categories the project belongs to */
|
||||
val categories: List<String> = emptyList(),
|
||||
|
||||
/** Additional categories */
|
||||
@SerialName("additional_categories")
|
||||
val additionalCategories: List<String> = emptyList(),
|
||||
|
||||
/** List of mod loaders supported */
|
||||
val loaders: List<String> = emptyList(),
|
||||
|
||||
/** List of version IDs associated with the project (newest first) */
|
||||
val versions: List<String> = emptyList(),
|
||||
|
||||
/** URL to the project icon */
|
||||
@SerialName("icon_url")
|
||||
val iconUrl: String? = null,
|
||||
|
||||
/** URL to the issue tracker */
|
||||
@SerialName("issues_url")
|
||||
val issuesUrl: String? = null,
|
||||
|
||||
/** URL to the source code repository */
|
||||
@SerialName("source_url")
|
||||
val sourceUrl: String? = null,
|
||||
|
||||
/** URL to the project's wiki */
|
||||
@SerialName("wiki_url")
|
||||
val wikiUrl: String? = null,
|
||||
|
||||
/** URL to the project's Discord */
|
||||
@SerialName("discord_url")
|
||||
val discordUrl: String? = null,
|
||||
|
||||
/** License information */
|
||||
val license: ProjectLicense? = null,
|
||||
|
||||
/** Client-side requirement */
|
||||
@SerialName("client_side")
|
||||
val clientSide: String? = null,
|
||||
|
||||
/** Server-side requirement */
|
||||
@SerialName("server_side")
|
||||
val serverSide: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package top.jie65535.jcf.model.modrinth
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* License information for a Modrinth project.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProjectLicense(
|
||||
/** The SPDX license identifier */
|
||||
val id: String,
|
||||
|
||||
/** The human-readable name of the license */
|
||||
val name: String,
|
||||
|
||||
/** URL to the full license text */
|
||||
val url: String? = null,
|
||||
)
|
||||
74
src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchHit.kt
Normal file
74
src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchHit.kt
Normal file
@@ -0,0 +1,74 @@
|
||||
package top.jie65535.jcf.model.modrinth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A single project returned by the Modrinth search endpoint.
|
||||
* See: https://docs.modrinth.com/api/#tag/projects/operation/searchProjects
|
||||
*/
|
||||
@Serializable
|
||||
data class SearchHit(
|
||||
/** The project's ID */
|
||||
@SerialName("project_id")
|
||||
val projectId: String,
|
||||
|
||||
/** The project type (mod, modpack, resourcepack, shader, plugin, datapack) */
|
||||
@SerialName("project_type")
|
||||
val projectType: String,
|
||||
|
||||
/** The project's URL slug */
|
||||
val slug: String,
|
||||
|
||||
/** The primary author's username */
|
||||
val author: String,
|
||||
|
||||
/** The project's display title */
|
||||
val title: String,
|
||||
|
||||
/** A short description of the project */
|
||||
val description: String,
|
||||
|
||||
/** The categories/tags the project belongs to */
|
||||
val categories: List<String> = emptyList(),
|
||||
|
||||
/** Categories shown in the UI */
|
||||
@SerialName("display_categories")
|
||||
val displayCategories: List<String> = emptyList(),
|
||||
|
||||
/** Game versions supported by the project */
|
||||
val versions: List<String> = emptyList(),
|
||||
|
||||
/** Total number of downloads */
|
||||
val downloads: Long,
|
||||
|
||||
/** Total number of followers */
|
||||
val follows: Int,
|
||||
|
||||
/** The URL of the project's icon */
|
||||
@SerialName("icon_url")
|
||||
val iconUrl: String? = null,
|
||||
|
||||
/** The creation date of the project (ISO 8601) */
|
||||
@SerialName("date_created")
|
||||
val dateCreated: String,
|
||||
|
||||
/** The last modification date (ISO 8601) */
|
||||
@SerialName("date_modified")
|
||||
val dateModified: String,
|
||||
|
||||
/** The version string of the latest release */
|
||||
@SerialName("latest_version")
|
||||
val latestVersion: String? = null,
|
||||
|
||||
/** The SPDX license identifier */
|
||||
val license: String? = null,
|
||||
|
||||
/** Client-side requirement */
|
||||
@SerialName("client_side")
|
||||
val clientSide: String? = null,
|
||||
|
||||
/** Server-side requirement */
|
||||
@SerialName("server_side")
|
||||
val serverSide: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package top.jie65535.jcf.model.modrinth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response from the Modrinth search endpoint.
|
||||
* See: https://docs.modrinth.com/api/#tag/projects/operation/searchProjects
|
||||
*/
|
||||
@Serializable
|
||||
data class SearchResponse(
|
||||
/** List of search results */
|
||||
val hits: List<SearchHit>,
|
||||
|
||||
/** The number of results skipped */
|
||||
val offset: Int,
|
||||
|
||||
/** The number of results per page */
|
||||
val limit: Int,
|
||||
|
||||
/** Total number of matching results */
|
||||
@SerialName("total_hits")
|
||||
val totalHits: Int,
|
||||
)
|
||||
62
src/main/kotlin/top/jie65535/jcf/model/modrinth/Version.kt
Normal file
62
src/main/kotlin/top/jie65535/jcf/model/modrinth/Version.kt
Normal file
@@ -0,0 +1,62 @@
|
||||
package top.jie65535.jcf.model.modrinth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A specific version/release of a Modrinth project.
|
||||
* See: https://docs.modrinth.com/api/#tag/versions/operation/getVersion
|
||||
*/
|
||||
@Serializable
|
||||
data class Version(
|
||||
/** The version's unique ID */
|
||||
val id: String,
|
||||
|
||||
/** The ID of the project this version belongs to */
|
||||
@SerialName("project_id")
|
||||
val projectId: String,
|
||||
|
||||
/** The ID of the author who published this version */
|
||||
@SerialName("author_id")
|
||||
val authorId: String,
|
||||
|
||||
/** Whether this version is marked as featured */
|
||||
val featured: Boolean = false,
|
||||
|
||||
/** The version's display name */
|
||||
val name: String,
|
||||
|
||||
/** The version number string */
|
||||
@SerialName("version_number")
|
||||
val versionNumber: String,
|
||||
|
||||
/** The version changelog in Markdown format */
|
||||
val changelog: String? = null,
|
||||
|
||||
/** The publication date (ISO 8601) */
|
||||
@SerialName("date_published")
|
||||
val datePublished: String,
|
||||
|
||||
/** Total downloads for this version */
|
||||
val downloads: Long,
|
||||
|
||||
/** The release channel (release, beta, alpha) */
|
||||
@SerialName("version_type")
|
||||
val versionType: String,
|
||||
|
||||
/** Version status */
|
||||
val status: String? = null,
|
||||
|
||||
/** Files attached to this version */
|
||||
val files: List<VersionFile> = emptyList(),
|
||||
|
||||
/** Dependencies this version requires */
|
||||
val dependencies: List<VersionDependency> = emptyList(),
|
||||
|
||||
/** Minecraft versions this release supports */
|
||||
@SerialName("game_versions")
|
||||
val gameVersions: List<String> = emptyList(),
|
||||
|
||||
/** Mod loaders this release supports */
|
||||
val loaders: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.jie65535.jcf.model.modrinth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A dependency declared by a Modrinth version.
|
||||
*/
|
||||
@Serializable
|
||||
data class VersionDependency(
|
||||
/** The version ID of the dependency (nullable) */
|
||||
@SerialName("version_id")
|
||||
val versionId: String? = null,
|
||||
|
||||
/** The project ID of the dependency (nullable) */
|
||||
@SerialName("project_id")
|
||||
val projectId: String? = null,
|
||||
|
||||
/** The filename hint for the dependency */
|
||||
@SerialName("file_name")
|
||||
val fileName: String? = null,
|
||||
|
||||
/** The dependency type (required, optional, incompatible, embedded) */
|
||||
@SerialName("dependency_type")
|
||||
val dependencyType: String,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package top.jie65535.jcf.model.modrinth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A file attached to a Modrinth version.
|
||||
*/
|
||||
@Serializable
|
||||
data class VersionFile(
|
||||
/** The download URL for this file */
|
||||
val url: String,
|
||||
|
||||
/** The file name */
|
||||
val filename: String,
|
||||
|
||||
/** Whether this is the primary file for the version */
|
||||
val primary: Boolean = false,
|
||||
|
||||
/** The file size in bytes */
|
||||
val size: Long,
|
||||
)
|
||||
106
src/test/kotlin/ModrinthApiTest.kt
Normal file
106
src/test/kotlin/ModrinthApiTest.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
package top.jie65535
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Test
|
||||
import top.jie65535.jcf.ModrinthApi
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ModrinthApiTest {
|
||||
companion object {
|
||||
// Sodium - one of the most popular Minecraft optimization mods on Modrinth
|
||||
private const val PROJECT_SLUG = "sodium"
|
||||
private const val PROJECT_TYPE = "mod"
|
||||
}
|
||||
|
||||
// No API key required for Modrinth read operations
|
||||
private val api = ModrinthApi()
|
||||
|
||||
@Test
|
||||
fun search() = runTest {
|
||||
val response = api.search(query = "sodium", facets = """[["project_type:mod"]]""", limit = 10)
|
||||
assertTrue(response.hits.isNotEmpty(), "Search should return results")
|
||||
assertTrue(response.totalHits > 0, "Total hits should be positive")
|
||||
printResult("search", response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchPagination() = runTest {
|
||||
val page1 = api.search(query = "fabric", facets = """[["project_type:mod"]]""", offset = 0, limit = 5)
|
||||
val page2 = api.search(query = "fabric", facets = """[["project_type:mod"]]""", offset = 5, limit = 5)
|
||||
assertEquals(5, page1.hits.size, "First page should have 5 results")
|
||||
assertEquals(5, page2.hits.size, "Second page should have 5 results")
|
||||
// Pages should not overlap
|
||||
val ids1 = page1.hits.map { it.projectId }.toSet()
|
||||
val ids2 = page2.hits.map { it.projectId }.toSet()
|
||||
assertTrue(ids1.intersect(ids2).isEmpty(), "Pages should not overlap")
|
||||
printResult("searchPagination page1", page1)
|
||||
printResult("searchPagination page2", page2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getProject() = runTest {
|
||||
val project = api.getProject(PROJECT_SLUG)
|
||||
assertEquals(PROJECT_SLUG, project.slug, "Slug should match")
|
||||
assertEquals(PROJECT_TYPE, project.projectType, "Project type should be mod")
|
||||
assertNotNull(project.id, "Project ID should not be null")
|
||||
assertTrue(project.downloads > 0, "Downloads should be positive")
|
||||
printResult("getProject", project)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getProjects() = runTest {
|
||||
// First get the project to find its ID
|
||||
val project = api.getProject(PROJECT_SLUG)
|
||||
val projects = api.getProjects(listOf(project.id))
|
||||
assertEquals(1, projects.size, "Should return exactly one project")
|
||||
assertEquals(project.id, projects[0].id, "Project ID should match")
|
||||
printResult("getProjects", projects)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getProjectVersions() = runTest {
|
||||
val versions = api.getProjectVersions(PROJECT_SLUG)
|
||||
assertTrue(versions.isNotEmpty(), "Project should have at least one version")
|
||||
// Verify version fields
|
||||
val first = versions.first()
|
||||
assertTrue(first.id.isNotBlank(), "Version ID should not be blank")
|
||||
assertTrue(first.name.isNotBlank(), "Version name should not be blank")
|
||||
assertTrue(first.files.isNotEmpty(), "Version should have at least one file")
|
||||
printResult("getProjectVersions (first)", first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getVersion() = runTest {
|
||||
val versions = api.getProjectVersions(PROJECT_SLUG)
|
||||
assertNotNull(versions.firstOrNull(), "Project should have at least one version")
|
||||
val versionId = versions.first().id
|
||||
val version = api.getVersion(versionId)
|
||||
assertEquals(versionId, version.id, "Version ID should match")
|
||||
printResult("getVersion", version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchModpack() = runTest {
|
||||
val response = api.search(query = "all of fabric", facets = """[["project_type:modpack"]]""", limit = 5)
|
||||
assertTrue(response.hits.isNotEmpty(), "Modpack search should return results")
|
||||
assertTrue(response.hits.all { it.projectType == "modpack" }, "All results should be modpacks")
|
||||
printResult("searchModpack", response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchResourcePack() = runTest {
|
||||
val response = api.search(query = "faithful", facets = """[["project_type:resourcepack"]]""", limit = 5)
|
||||
assertTrue(response.hits.isNotEmpty(), "Resource pack search should return results")
|
||||
printResult("searchResourcePack", response)
|
||||
}
|
||||
|
||||
private inline fun <reified T> printResult(name: String, obj: T) {
|
||||
println("$name result: ${Json.encodeToString(obj)}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user