diff --git a/build.gradle.kts b/build.gradle.kts index 35464a9..346da14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/kotlin/top/jie65535/jcf/MessageHandler.kt b/src/main/kotlin/top/jie65535/jcf/MessageHandler.kt index fab0927..5f76db7 100644 --- a/src/main/kotlin/top/jie65535/jcf/MessageHandler.kt +++ b/src/main/kotlin/top/jie65535/jcf/MessageHandler.kt @@ -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 { diff --git a/src/main/kotlin/top/jie65535/jcf/ModrinthApi.kt b/src/main/kotlin/top/jie65535/jcf/ModrinthApi.kt new file mode 100644 index 0000000..76b8651 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/ModrinthApi.kt @@ -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()) + } + + /** + * 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()) + } + + /** + * Get multiple projects at once by their IDs. + * @param ids List of project IDs. + */ + suspend fun getProjects(ids: List): List { + val response = http.get("/v2/projects") { + parameter("ids", json.encodeToString(ListSerializer(String.serializer()), ids)) + } + return json.decodeFromString(response.body()) + } + + //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? = null, + gameVersions: List? = null, + featured: Boolean? = null, + ): List { + 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()) + } + + /** + * 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()) + } + + //endregion +} diff --git a/src/main/kotlin/top/jie65535/jcf/ModrinthMessageHandler.kt b/src/main/kotlin/top/jie65535/jcf/ModrinthMessageHandler.kt new file mode 100644 index 0000000..2dd8697 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/ModrinthMessageHandler.kt @@ -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, + 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 MessageEvent.handlePagedList( + pagedList: PagedList, + 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(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) { + 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(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) { + 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 + } + } +} diff --git a/src/main/kotlin/top/jie65535/jcf/ModrinthService.kt b/src/main/kotlin/top/jie65535/jcf/ModrinthService.kt new file mode 100644 index 0000000..451cf96 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/ModrinthService.kt @@ -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 = + 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 { + 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): List = api.getProjects(ids) + + /** + * Fetch a single version by ID. + */ + suspend fun getVersion(versionId: String): Version = api.getVersion(versionId) +} diff --git a/src/main/kotlin/top/jie65535/jcf/ModrinthSubscribeHandler.kt b/src/main/kotlin/top/jie65535/jcf/ModrinthSubscribeHandler.kt new file mode 100644 index 0000000..2b2021e --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/ModrinthSubscribeHandler.kt @@ -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 = mutableMapOf() + } +} diff --git a/src/main/kotlin/top/jie65535/jcf/PluginCommands.kt b/src/main/kotlin/top/jie65535/jcf/PluginCommands.kt index e78a0c7..ba8c01a 100644 --- a/src/main/kotlin/top/jie65535/jcf/PluginCommands.kt +++ b/src/main/kotlin/top/jie65535/jcf/PluginCommands.kt @@ -15,9 +15,14 @@ object PluginCommands : CompositeCommand(PluginMain, "jcf") { @Description("查看插件帮助") suspend fun CommandSender.help() { val msg = StringBuilder() + msg.appendLine("=== CurseForge 命令 ===") for ((modClass, cmd) in PluginConfig.searchCommands) { msg.appendLine("搜索${modClass.className}: $cmd") } + msg.appendLine("=== Modrinth 命令 ===") + for ((projectType, cmd) in PluginConfig.mrSearchCommands) { + msg.appendLine("搜索${projectType.typeName}: $cmd") + } sendMessage(msg.toString()) } @@ -36,27 +41,64 @@ object PluginCommands : CompositeCommand(PluginMain, "jcf") { } @SubCommand - @Description("查看订阅处理的状态") + @Description("查看 CurseForge 订阅处理的状态") suspend fun CommandSender.subStat() { + if (!PluginMain.isCurseForgeEnabled) { + sendMessage("CurseForge 未配置 API Key,订阅功能不可用") + return + } val subs = PluginMain.subscribeHandler if (subs.isIdle) { - sendMessage("订阅器闲置中") + sendMessage("CurseForge 订阅器闲置中") } else { - sendMessage("订阅处理正常运行中") + sendMessage("CurseForge 订阅处理正常运行中") } } @SubCommand - @Description("使订阅器闲置") + @Description("使 CurseForge 订阅器闲置") suspend fun CommandSender.idleSubs() { + if (!PluginMain.isCurseForgeEnabled) { + sendMessage("CurseForge 未配置 API Key,订阅功能不可用") + return + } PluginMain.subscribeHandler.idle() sendMessage("OK,已闲置") } @SubCommand - @Description("使订阅器恢复运行") + @Description("使 CurseForge 订阅器恢复运行") suspend fun CommandSender.runSubs() { + if (!PluginMain.isCurseForgeEnabled) { + sendMessage("CurseForge 未配置 API Key,订阅功能不可用") + return + } PluginMain.subscribeHandler.start() - sendMessage("OK,已恢复订阅处理") + sendMessage("OK,已恢复 CurseForge 订阅处理") + } + + @SubCommand + @Description("查看 Modrinth 订阅处理的状态") + suspend fun CommandSender.mrSubStat() { + val subs = PluginMain.modrinthSubscribeHandler + if (subs.isIdle) { + sendMessage("Modrinth 订阅器闲置中") + } else { + sendMessage("Modrinth 订阅处理正常运行中") + } + } + + @SubCommand + @Description("使 Modrinth 订阅器闲置") + suspend fun CommandSender.mrIdleSubs() { + PluginMain.modrinthSubscribeHandler.idle() + sendMessage("OK,Modrinth 订阅器已闲置") + } + + @SubCommand + @Description("使 Modrinth 订阅器恢复运行") + suspend fun CommandSender.mrRunSubs() { + PluginMain.modrinthSubscribeHandler.start() + sendMessage("OK,已恢复 Modrinth 订阅处理") } } diff --git a/src/main/kotlin/top/jie65535/jcf/PluginConfig.kt b/src/main/kotlin/top/jie65535/jcf/PluginConfig.kt index 39d78b2..9a2aa00 100644 --- a/src/main/kotlin/top/jie65535/jcf/PluginConfig.kt +++ b/src/main/kotlin/top/jie65535/jcf/PluginConfig.kt @@ -21,6 +21,18 @@ object PluginConfig : AutoSavePluginConfig("JCurseforgeConfig") { ) ) + @ValueDescription("Modrinth 搜索命令 (MODS,MODPACKS,RESOURCE_PACKS,SHADERS,PLUGINS,DATA_PACKS)") + val mrSearchCommands: MutableMap 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 */ diff --git a/src/main/kotlin/top/jie65535/jcf/PluginData.kt b/src/main/kotlin/top/jie65535/jcf/PluginData.kt index 3082cef..b906a8b 100644 --- a/src/main/kotlin/top/jie65535/jcf/PluginData.kt +++ b/src/main/kotlin/top/jie65535/jcf/PluginData.kt @@ -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 by value() /** - * 订阅记录 + * 订阅记录 (CurseForge) * ```json * { * mod_id: { @@ -33,4 +33,32 @@ object PluginData : AutoSavePluginData("JCurseforgeData") { */ @ValueDescription("订阅记录") var subscriptionSet: MutableMap>> by value() + + /** + * Modrinth 项目最新版本id集合 + * ```json + * { + * project_id: version_id, + * ... + * } + * ``` + */ + @ValueDescription("Modrinth 项目最新版本id集合") + var mrProjectsLastVersion: MutableMap by value() + + /** + * Modrinth 订阅记录 + * ```json + * { + * project_id: { + * group_id: [ qq_id, ... ], + * ... + * }, + * ... + * } + * ``` + * 个人订阅时,group为0 + */ + @ValueDescription("Modrinth 订阅记录") + var mrSubscriptionSet: MutableMap>> by value() } diff --git a/src/main/kotlin/top/jie65535/jcf/PluginMain.kt b/src/main/kotlin/top/jie65535/jcf/PluginMain.kt index 89db064..24f003a 100644 --- a/src/main/kotlin/top/jie65535/jcf/PluginMain.kt +++ b/src/main/kotlin/top/jie65535/jcf/PluginMain.kt @@ -11,39 +11,64 @@ object PluginMain: KotlinPlugin( JvmPluginDescription( id = "top.jie65535.jcf", name = "J Curseforge Util", - version = "1.1.0", + version = "1.2.0", ) { author("jie65535") - info("MC Curseforge Util\n" + + info("MC Curseforge & Modrinth Util\n" + "https://github.com/jie65535/mirai-console-jcf-plugin") } ) { /** - * 订阅处理类 + * CurseForge 订阅处理类 */ lateinit var subscribeHandler: SubscribeHandler private set + /** + * Modrinth 订阅处理类 + */ + lateinit var modrinthSubscribeHandler: ModrinthSubscribeHandler private set + + /** + * CurseForge is enabled only when an API key is configured. + */ + var isCurseForgeEnabled = false + private set + override fun onEnable() { logger.info { "Plugin loaded" } PluginData.reload() PluginConfig.reload() PluginCommands.register() + val eventChannel = GlobalEventChannel.parentScope(this) + + // Initialize Modrinth (no API key required) + val modrinthService = ModrinthService() + modrinthSubscribeHandler = ModrinthSubscribeHandler(modrinthService, logger) + val modrinthMessageHandler = ModrinthMessageHandler(modrinthService, modrinthSubscribeHandler, eventChannel, logger) + modrinthMessageHandler.startListen() + launch { + modrinthSubscribeHandler.load(this) + } + modrinthSubscribeHandler.start() + + // Initialize CurseForge (requires API key) if (PluginConfig.apiKey.isBlank()) { - logger.error("必须配置 Curseforge Api Key 才可以使用本插件!\n" + + logger.error("未配置 Curseforge Api Key,CurseForge 相关功能不可用!\n" + "请使用 /jcf setApiKey 命令来设置key\n" + "Api key 可以在开发者控制台生成:https://console.curseforge.com/") - return + } else { + val service = MinecraftService(PluginConfig.apiKey) + val messageHandler = MessageHandler(service, eventChannel, logger) + subscribeHandler = SubscribeHandler(service, logger) + messageHandler.startListen() + launch { + subscribeHandler.load(this) + } + subscribeHandler.start() + isCurseForgeEnabled = true } - val service = MinecraftService(PluginConfig.apiKey) - val eventChannel = GlobalEventChannel.parentScope(this) - val messageHandler = MessageHandler(service, eventChannel, logger) - subscribeHandler = SubscribeHandler(service, logger) - messageHandler.startListen() - launch { - subscribeHandler.load(this) - } - subscribeHandler.start() + logger.info { "Plugin Enabled" } } } diff --git a/src/main/kotlin/top/jie65535/jcf/model/modrinth/Project.kt b/src/main/kotlin/top/jie65535/jcf/model/modrinth/Project.kt new file mode 100644 index 0000000..93f0dc5 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/model/modrinth/Project.kt @@ -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 = emptyList(), + + /** Additional categories */ + @SerialName("additional_categories") + val additionalCategories: List = emptyList(), + + /** List of mod loaders supported */ + val loaders: List = emptyList(), + + /** List of version IDs associated with the project (newest first) */ + val versions: List = 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, +) diff --git a/src/main/kotlin/top/jie65535/jcf/model/modrinth/ProjectLicense.kt b/src/main/kotlin/top/jie65535/jcf/model/modrinth/ProjectLicense.kt new file mode 100644 index 0000000..8461a03 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/model/modrinth/ProjectLicense.kt @@ -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, +) diff --git a/src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchHit.kt b/src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchHit.kt new file mode 100644 index 0000000..2b7ea55 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchHit.kt @@ -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 = emptyList(), + + /** Categories shown in the UI */ + @SerialName("display_categories") + val displayCategories: List = emptyList(), + + /** Game versions supported by the project */ + val versions: List = 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, +) diff --git a/src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchResponse.kt b/src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchResponse.kt new file mode 100644 index 0000000..29423d9 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/model/modrinth/SearchResponse.kt @@ -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, + + /** 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, +) diff --git a/src/main/kotlin/top/jie65535/jcf/model/modrinth/Version.kt b/src/main/kotlin/top/jie65535/jcf/model/modrinth/Version.kt new file mode 100644 index 0000000..34f7f90 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/model/modrinth/Version.kt @@ -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 = emptyList(), + + /** Dependencies this version requires */ + val dependencies: List = emptyList(), + + /** Minecraft versions this release supports */ + @SerialName("game_versions") + val gameVersions: List = emptyList(), + + /** Mod loaders this release supports */ + val loaders: List = emptyList(), +) diff --git a/src/main/kotlin/top/jie65535/jcf/model/modrinth/VersionDependency.kt b/src/main/kotlin/top/jie65535/jcf/model/modrinth/VersionDependency.kt new file mode 100644 index 0000000..69de4eb --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/model/modrinth/VersionDependency.kt @@ -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, +) diff --git a/src/main/kotlin/top/jie65535/jcf/model/modrinth/VersionFile.kt b/src/main/kotlin/top/jie65535/jcf/model/modrinth/VersionFile.kt new file mode 100644 index 0000000..4c38e61 --- /dev/null +++ b/src/main/kotlin/top/jie65535/jcf/model/modrinth/VersionFile.kt @@ -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, +) diff --git a/src/test/kotlin/ModrinthApiTest.kt b/src/test/kotlin/ModrinthApiTest.kt new file mode 100644 index 0000000..6df68be --- /dev/null +++ b/src/test/kotlin/ModrinthApiTest.kt @@ -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 printResult(name: String, obj: T) { + println("$name result: ${Json.encodeToString(obj)}") + } +}