mirror of
https://github.com/jie65535/mirai-console-jcf-plugin.git
synced 2026-05-04 23:23:40 +08:00
Compare commits
2 Commits
1ffd53cab1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d5a52820 | ||
|
|
2709492646 |
34
.github/workflows/build.yml
vendored
Normal file
34
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Build Plugin
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master, main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 11
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '11'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Build Plugin
|
||||||
|
run: ./gradlew buildPlugin
|
||||||
|
|
||||||
|
- name: Upload Plugin Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: plugin
|
||||||
|
path: build/mirai/
|
||||||
43
README.md
43
README.md
@@ -1,30 +1,55 @@
|
|||||||
# mirai-console-jcf-plugin
|
# mirai-console-jcf-plugin
|
||||||
基于Mirai Console的Curseforge插件
|
基于Mirai Console的Curseforge与Modrinth插件
|
||||||
|
|
||||||
# 请注意:本插件需要申请 [Curseforge Api Key](https://console.curseforge.com/) 才可使用!!
|
[](https://github.com/jie65535/mirai-console-jcf-plugin/actions/workflows/build.yml)
|
||||||
|
|
||||||
|
# 请注意:使用 CurseForge 功能需要申请 [Curseforge Api Key](https://console.curseforge.com/)!Modrinth 功能无需 API Key 即可使用。
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
允许用户通过`QQ`对[Curseforge](https://www.curseforge.com/)网站进行搜索查询
|
允许用户通过`QQ`对[Curseforge](https://www.curseforge.com/)和[Modrinth](https://modrinth.com/)网站进行搜索查询
|
||||||
|
|
||||||
现在支持搜索`Minecraft`相关内容,可以通过命令搜索模组、整合包、资源包。
|
现在支持搜索`Minecraft`相关内容,可以通过命令搜索模组、整合包、资源包等。
|
||||||
|
|
||||||
支持查看文件列表与其下载地址,单独查看文件的更新日志。
|
支持查看文件列表与其下载地址,单独查看文件的更新日志。
|
||||||
|
|
||||||
|
支持订阅项目更新,有新版本时自动推送通知。
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
指令
|
指令
|
||||||
- /jcf help # 查看帮助
|
- /jcf help # 查看帮助
|
||||||
- /jcf setApiKey # 设置Curseforge API Key
|
- /jcf setApiKey # 设置Curseforge API Key
|
||||||
|
- /jcf setSubsSender \<qq\> # 设置订阅信息推送bot(qq id)
|
||||||
|
- /jcf setCheckInterval \<seconds\> # 设置更新检查间隔(单位:秒)
|
||||||
|
|
||||||
分类搜索命令(可配置)
|
### CurseForge 分类搜索命令(可配置)
|
||||||
- 搜索模组: cfmod \<filter\>
|
- 搜索模组: cfmod \<filter\>
|
||||||
- 搜索整合包: cfpack \<filter\>
|
- 搜索整合包: cfpack \<filter\>
|
||||||
- 搜索资源包: cfres \<filter\>
|
- 搜索资源包: cfres \<filter\>
|
||||||
- 搜索存档: cfword \<filter\>
|
- 搜索存档: cfworld \<filter\>
|
||||||
- 搜索水桶服插件: cfbukkit \<filter\>
|
- 搜索水桶服插件: cfbukkit \<filter\>
|
||||||
- 搜索附加: cfaddon \<filter\>
|
- 搜索附加: cfaddon \<filter\>
|
||||||
- 搜索定制: cfcustom \<filter\>
|
- 搜索定制: cfcustom \<filter\>
|
||||||
|
|
||||||
|
### Modrinth 分类搜索命令(可配置)
|
||||||
|
- 搜索模组: mrmod \<filter\>
|
||||||
|
- 搜索整合包: mrpack \<filter\>
|
||||||
|
- 搜索资源包: mrres \<filter\>
|
||||||
|
- 搜索光影: mrshader \<filter\>
|
||||||
|
- 搜索插件: mrplugin \<filter\>
|
||||||
|
- 搜索数据包: mrdata \<filter\>
|
||||||
|
|
||||||
|
### 订阅管理命令
|
||||||
|
**CurseForge 订阅**
|
||||||
|
- /jcf subStat # 查看 CurseForge 订阅处理状态
|
||||||
|
- /jcf idleSubs # 使 CurseForge 订阅器闲置
|
||||||
|
- /jcf runSubs # 使 CurseForge 订阅器恢复运行
|
||||||
|
|
||||||
|
**Modrinth 订阅**
|
||||||
|
- /jcf mrSubStat # 查看 Modrinth 订阅处理状态
|
||||||
|
- /jcf mrIdleSubs # 使 Modrinth 订阅器闲置
|
||||||
|
- /jcf mrRunSubs # 使 Modrinth 订阅器恢复运行
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
@@ -51,7 +76,11 @@
|
|||||||
- [x] 获取文件下载地址
|
- [x] 获取文件下载地址
|
||||||
- [ ] 获取依赖的项目
|
- [ ] 获取依赖的项目
|
||||||
---
|
---
|
||||||
- [ ] 模组更新订阅,更新时通知订阅者
|
- [x] 模组更新订阅,更新时通知订阅者
|
||||||
|
- [x] 集成 [Modrinth](https://modrinth.com/) 平台
|
||||||
|
- [x] 搜索模组、整合包、资源包、光影、插件、数据包
|
||||||
|
- [x] 查看项目详情与版本列表
|
||||||
|
- [x] 订阅项目更新通知
|
||||||
- [ ] 设置代理
|
- [ ] 设置代理
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "top.jie65535.jcf"
|
group = "top.jie65535.jcf"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven("https://maven.aliyun.com/repository/public")
|
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:$ktorVersion")
|
||||||
implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
|
implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
|
||||||
implementation("io.ktor:ktor-client-okhttp:$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)
|
val pagedList = service.search(modClass, filter)
|
||||||
with(pagedList.current()) {
|
with(pagedList.current()) {
|
||||||
if (isEmpty()) {
|
if (isEmpty()) {
|
||||||
subject.sendMessage("未搜索到关键字\"$filter\"相关结果")
|
subject.sendMessage("未搜索到相关结果")
|
||||||
} else if (size == 1) {
|
} else if (size == 1) {
|
||||||
handleShowMod(get(0))
|
handleShowMod(get(0))
|
||||||
} else {
|
} 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("查看插件帮助")
|
@Description("查看插件帮助")
|
||||||
suspend fun CommandSender.help() {
|
suspend fun CommandSender.help() {
|
||||||
val msg = StringBuilder()
|
val msg = StringBuilder()
|
||||||
|
msg.appendLine("=== CurseForge 命令 ===")
|
||||||
for ((modClass, cmd) in PluginConfig.searchCommands) {
|
for ((modClass, cmd) in PluginConfig.searchCommands) {
|
||||||
msg.appendLine("搜索${modClass.className}: $cmd")
|
msg.appendLine("搜索${modClass.className}: $cmd")
|
||||||
}
|
}
|
||||||
|
msg.appendLine("=== Modrinth 命令 ===")
|
||||||
|
for ((projectType, cmd) in PluginConfig.mrSearchCommands) {
|
||||||
|
msg.appendLine("搜索${projectType.typeName}: $cmd")
|
||||||
|
}
|
||||||
sendMessage(msg.toString())
|
sendMessage(msg.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,27 +41,64 @@ object PluginCommands : CompositeCommand(PluginMain, "jcf") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SubCommand
|
@SubCommand
|
||||||
@Description("查看订阅处理的状态")
|
@Description("查看 CurseForge 订阅处理的状态")
|
||||||
suspend fun CommandSender.subStat() {
|
suspend fun CommandSender.subStat() {
|
||||||
|
if (!PluginMain.isCurseForgeEnabled) {
|
||||||
|
sendMessage("CurseForge 未配置 API Key,订阅功能不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
val subs = PluginMain.subscribeHandler
|
val subs = PluginMain.subscribeHandler
|
||||||
if (subs.isIdle) {
|
if (subs.isIdle) {
|
||||||
sendMessage("订阅器闲置中")
|
sendMessage("CurseForge 订阅器闲置中")
|
||||||
} else {
|
} else {
|
||||||
sendMessage("订阅处理正常运行中")
|
sendMessage("CurseForge 订阅处理正常运行中")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubCommand
|
@SubCommand
|
||||||
@Description("使订阅器闲置")
|
@Description("使 CurseForge 订阅器闲置")
|
||||||
suspend fun CommandSender.idleSubs() {
|
suspend fun CommandSender.idleSubs() {
|
||||||
|
if (!PluginMain.isCurseForgeEnabled) {
|
||||||
|
sendMessage("CurseForge 未配置 API Key,订阅功能不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
PluginMain.subscribeHandler.idle()
|
PluginMain.subscribeHandler.idle()
|
||||||
sendMessage("OK,已闲置")
|
sendMessage("OK,已闲置")
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubCommand
|
@SubCommand
|
||||||
@Description("使订阅器恢复运行")
|
@Description("使 CurseForge 订阅器恢复运行")
|
||||||
suspend fun CommandSender.runSubs() {
|
suspend fun CommandSender.runSubs() {
|
||||||
|
if (!PluginMain.isCurseForgeEnabled) {
|
||||||
|
sendMessage("CurseForge 未配置 API Key,订阅功能不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
PluginMain.subscribeHandler.start()
|
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
|
* 订阅信息推送bot
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import net.mamoe.mirai.console.data.ValueDescription
|
|||||||
object PluginData : AutoSavePluginData("JCurseforgeData") {
|
object PluginData : AutoSavePluginData("JCurseforgeData") {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模组最新文件的id集合
|
* 模组最新文件的id集合 (CurseForge)
|
||||||
* ```json
|
* ```json
|
||||||
* {
|
* {
|
||||||
* mod_id: file_id,
|
* mod_id: file_id,
|
||||||
@@ -19,7 +19,7 @@ object PluginData : AutoSavePluginData("JCurseforgeData") {
|
|||||||
var modsLastFile: MutableMap<Int, Int> by value()
|
var modsLastFile: MutableMap<Int, Int> by value()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅记录
|
* 订阅记录 (CurseForge)
|
||||||
* ```json
|
* ```json
|
||||||
* {
|
* {
|
||||||
* mod_id: {
|
* mod_id: {
|
||||||
@@ -33,4 +33,32 @@ object PluginData : AutoSavePluginData("JCurseforgeData") {
|
|||||||
*/
|
*/
|
||||||
@ValueDescription("订阅记录")
|
@ValueDescription("订阅记录")
|
||||||
var subscriptionSet: MutableMap<Int, MutableMap<Long, MutableList<Long>>> by value()
|
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,32 +11,54 @@ object PluginMain: KotlinPlugin(
|
|||||||
JvmPluginDescription(
|
JvmPluginDescription(
|
||||||
id = "top.jie65535.jcf",
|
id = "top.jie65535.jcf",
|
||||||
name = "J Curseforge Util",
|
name = "J Curseforge Util",
|
||||||
version = "1.1.0",
|
version = "1.2.0",
|
||||||
) {
|
) {
|
||||||
author("jie65535")
|
author("jie65535")
|
||||||
info("MC Curseforge Util\n" +
|
info("MC Curseforge & Modrinth Util\n" +
|
||||||
"https://github.com/jie65535/mirai-console-jcf-plugin")
|
"https://github.com/jie65535/mirai-console-jcf-plugin")
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* 订阅处理类
|
* CurseForge 订阅处理类
|
||||||
*/
|
*/
|
||||||
lateinit var subscribeHandler: SubscribeHandler private set
|
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() {
|
override fun onEnable() {
|
||||||
logger.info { "Plugin loaded" }
|
logger.info { "Plugin loaded" }
|
||||||
PluginData.reload()
|
PluginData.reload()
|
||||||
PluginConfig.reload()
|
PluginConfig.reload()
|
||||||
PluginCommands.register()
|
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()) {
|
if (PluginConfig.apiKey.isBlank()) {
|
||||||
logger.error("必须配置 Curseforge Api Key 才可以使用本插件!\n" +
|
logger.error("未配置 Curseforge Api Key,CurseForge 相关功能不可用!\n" +
|
||||||
"请使用 /jcf setApiKey <apiKey> 命令来设置key\n" +
|
"请使用 /jcf setApiKey <apiKey> 命令来设置key\n" +
|
||||||
"Api key 可以在开发者控制台生成:https://console.curseforge.com/")
|
"Api key 可以在开发者控制台生成:https://console.curseforge.com/")
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
val service = MinecraftService(PluginConfig.apiKey)
|
val service = MinecraftService(PluginConfig.apiKey)
|
||||||
val eventChannel = GlobalEventChannel.parentScope(this)
|
|
||||||
val messageHandler = MessageHandler(service, eventChannel, logger)
|
val messageHandler = MessageHandler(service, eventChannel, logger)
|
||||||
subscribeHandler = SubscribeHandler(service, logger)
|
subscribeHandler = SubscribeHandler(service, logger)
|
||||||
messageHandler.startListen()
|
messageHandler.startListen()
|
||||||
@@ -44,6 +66,9 @@ object PluginMain: KotlinPlugin(
|
|||||||
subscribeHandler.load(this)
|
subscribeHandler.load(this)
|
||||||
}
|
}
|
||||||
subscribeHandler.start()
|
subscribeHandler.start()
|
||||||
|
isCurseForgeEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
logger.info { "Plugin Enabled" }
|
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