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:
Copilot
2026-03-11 14:37:32 +08:00
committed by GitHub
parent 1ffd53cab1
commit 2709492646
19 changed files with 1308 additions and 24 deletions

View File

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

0
gradlew vendored Normal file → Executable file
View File

View File

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

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

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

View 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)
}

View 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()
}
}

View File

@@ -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("OKModrinth 订阅器已闲置")
}
@SubCommand
@Description("使 Modrinth 订阅器恢复运行")
suspend fun CommandSender.mrRunSubs() {
PluginMain.modrinthSubscribeHandler.start()
sendMessage("OK已恢复 Modrinth 订阅处理")
}
}

View File

@@ -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
*/

View File

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

View File

@@ -11,32 +11,54 @@ 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 KeyCurseForge 相关功能不可用\n" +
"请使用 /jcf setApiKey <apiKey> 命令来设置key\n" +
"Api key 可以在开发者控制台生成https://console.curseforge.com/")
return
}
} else {
val service = MinecraftService(PluginConfig.apiKey)
val eventChannel = GlobalEventChannel.parentScope(this)
val messageHandler = MessageHandler(service, eventChannel, logger)
subscribeHandler = SubscribeHandler(service, logger)
messageHandler.startListen()
@@ -44,6 +66,9 @@ object PluginMain: KotlinPlugin(
subscribeHandler.load(this)
}
subscribeHandler.start()
isCurseForgeEnabled = true
}
logger.info { "Plugin Enabled" }
}
}

View 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,
)

View File

@@ -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,
)

View 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,
)

View File

@@ -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,
)

View 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(),
)

View File

@@ -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,
)

View File

@@ -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,
)

View 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)}")
}
}