Compare commits

..

31 Commits

Author SHA1 Message Date
Copilot
26d5a52820 Add GitHub Actions build workflow (#14)
Some checks failed
Build Plugin / build (push) Has been cancelled
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jie65535 <29452349+jie65535@users.noreply.github.com>
2026-03-11 14:48:00 +08:00
Copilot
2709492646 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>
2026-03-11 14:37:32 +08:00
1ffd53cab1 更新依赖到最新版本
更新插件版本号到v1.1.0
2023-01-27 17:08:28 +08:00
184a6d9228 Create LICENSE 2022-09-14 15:31:03 +08:00
e6e03002e0 Merge pull request #9 from RogenDong/sub
实现订阅功能
2022-08-13 15:05:56 +08:00
33336d574d Merge pull request #10 from RogenDong/fix
修复空页异常
2022-08-13 15:05:43 +08:00
dongRogen
a6140577cb 修复空页异常 2022-08-12 19:02:46 +08:00
dongRogen
b2090cc09b 添加可切换闲置状态的命令 2022-08-09 20:32:39 +08:00
dongRogen
21bcedcb17 调整 检查mod更新处改用批量搜索 2022-08-04 11:40:31 +08:00
dongRogen
09974aad9e 添加订阅处理
- 实现订阅处理 SubscribeHandler
- 消息处理中的 HTMLPattern 设为 public
- 补充用户回复订阅的处理
- PluginMain 启用订阅
2022-08-04 10:59:10 +08:00
dongRogen
f1e6e2fbf5 添加订阅配置与持久化对象
- 添加配置:检查间隔、订阅信息推送bot
- 添加数据:订阅记录、模组最新文件的id集合
2022-08-04 10:01:51 +08:00
682cdbeb38 Merge pull request #6 from RogenDong/master
修复翻页bug #5
2022-07-22 23:25:54 +08:00
dongRogen
8d70e376df 修复翻页bug #5
- 更正传递给 cf 接口的索引
- 修复往前翻页时缺失“下一页”文本的问题
2022-07-22 23:02:06 +08:00
25c756f307 修复cfworld命令的语法错误 2022-06-19 18:03:48 +08:00
3bac45c052 修复作者、logo可能为空问题 2022-06-19 16:41:05 +08:00
a9e45470fa 修复回复错误信息到私聊的问题 2022-06-19 14:26:48 +08:00
37a70e211e [重大变化] 插件重构初步完成
移除旧代码
更新版本到v1.0.0
2022-06-19 11:12:57 +08:00
382b485fa7 Merge pull request #4 from RogenDong/master
实现消息拼接
2022-06-19 08:41:01 +08:00
dongRogen
31f4358ddb 实现搜索结果拼接为消息
添加 HttpUtil
2022-06-18 21:57:41 +08:00
b3a28ef982 Updated .gitignore 2022-06-18 18:38:11 +08:00
202a80ce45 Added new PluginMain
Added PluginConfig
2022-06-18 14:21:39 +08:00
afd06d7319 Added MessageHandler 2022-06-18 11:12:24 +08:00
75bea8e6ce Added Minecraft Service
Added PagedList
2022-06-18 11:01:28 +08:00
0558bbfd07 Added Curseforge Api Tests
Upgraded kotlin version to 1.6.21
Upgraded mirai console version to 2.11.1
2022-06-17 23:41:26 +08:00
be20a22fe9 fix apis 2022-06-14 21:14:20 +08:00
917d5f2a68 Encapsulates most APIs 2022-06-14 20:48:07 +08:00
7fd31c194b 新增官方API封装,当前仅完成了搜索Mod部分 2022-06-13 22:23:00 +08:00
筱傑
76d3cf2f9c 修复 文件列表太长无法显示问题 2021-11-10 13:28:22 +08:00
筱傑
d95b54182b Update README.md 2021-11-01 22:08:19 +08:00
筱傑
a7d1370c39 Add Search addon by project id
Add `/jcf id` Command
2021-11-01 22:04:55 +08:00
筱傑
05a50b3b44 Fix paging index error 2021-11-01 20:12:09 +08:00
130 changed files with 4221 additions and 2135 deletions

34
.github/workflows/build.yml vendored Normal file
View 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/

7
.gitignore vendored
View File

@@ -119,3 +119,10 @@ run/
# Local Test Launch point # Local Test Launch point
src/test/kotlin/RunTerminal.kt src/test/kotlin/RunTerminal.kt
/api_key.txt
/config
/data
/logs
/plugin-libraries
/plugin-shared-libraries
/plugins

1034
LICENSE

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,54 @@
# mirai-console-jcf-plugin # mirai-console-jcf-plugin
基于Mirai Console的Curseforge插件 基于Mirai Console的Curseforge与Modrinth插件
[![Build Plugin](https://github.com/jie65535/mirai-console-jcf-plugin/actions/workflows/build.yml/badge.svg)](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 ss \<filter\> # 直接搜索 - /jcf help # 查看帮助
- /jcf sspack \<filter\> # 搜索整合包 - /jcf setApiKey # 设置Curseforge API Key
- /jcf ssmod \<filter\> # 搜索模组 - /jcf setSubsSender \<qq\> # 设置订阅信息推送botqq id
- /jcf ssres \<filter\> # 搜索资源包 - /jcf setCheckInterval \<seconds\> # 设置更新检查间隔(单位:秒)
### CurseForge 分类搜索命令(可配置)
- 搜索模组: cfmod \<filter\>
- 搜索整合包: cfpack \<filter\>
- 搜索资源包: cfres \<filter\>
- 搜索存档: cfworld \<filter\>
- 搜索水桶服插件: cfbukkit \<filter\>
- 搜索附加: cfaddon \<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
@@ -32,6 +66,7 @@
- [x] 搜索整合包 - [x] 搜索整合包
- [x] 搜索资源包 - [x] 搜索资源包
- [x] ~~搜索存档~~ - [x] ~~搜索存档~~
- [ ] 根据项目ID搜索
--- ---
- [x] 分页选择 - [x] 分页选择
- [ ] 获取介绍 - [ ] 获取介绍
@@ -41,12 +76,14 @@
- [x] 获取文件下载地址 - [x] 获取文件下载地址
- [ ] 获取依赖的项目 - [ ] 获取依赖的项目
--- ---
- [ ] 模组更新订阅,更新时通知订阅者 - [x] 模组更新订阅,更新时通知订阅者
- [x] 集成 [Modrinth](https://modrinth.com/) 平台
- [x] 搜索模组、整合包、资源包、光影、插件、数据包
- [x] 查看项目详情与版本列表
- [x] 订阅项目更新通知
- [ ] 设置代理 - [ ] 设置代理
## 鸣谢 ## 鸣谢
- [Mirai](https://github.com/mamoe/mirai) 提供机器人平台 - [Mirai](https://github.com/mamoe/mirai) 提供机器人平台
- [Mirai Console](https://github.com/mamoe/mirai-console) 开放插件接入 - [Mirai Console](https://github.com/mamoe/mirai-console) 开放插件接入
- [Curseforge API](https://github.com/Gaz492/CurseforgeAPI) 提供`Curseforge API`
- [Curseforge API Kotlin library](https://github.com/pearxteam/cursekt) 提供`Curseforge API``kotlin`封装

View File

@@ -1,15 +1,25 @@
plugins { plugins {
val kotlinVersion = "1.5.10" val kotlinVersion = "1.7.10"
kotlin("jvm") version kotlinVersion kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion kotlin("plugin.serialization") version kotlinVersion
id("net.mamoe.mirai-console") version "2.7.0" id("net.mamoe.mirai-console") version "2.13.2"
} }
group = "me.jie65535" group = "top.jie65535.jcf"
version = "0.1.0" version = "1.2.0"
repositories { repositories {
maven("https://maven.aliyun.com/repository/public") maven("https://maven.aliyun.com/repository/public")
mavenCentral() mavenCentral()
}
val ktorVersion = "2.2.2"
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")
} }

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

0
gradlew vendored Normal file → Executable file
View File

View File

@@ -1,229 +0,0 @@
package me.jie65535.jcf
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.jie65535.jcf.model.addon.*
import me.jie65535.jcf.model.addon.file.*
import me.jie65535.jcf.model.addon.request.*
import me.jie65535.jcf.model.category.*
import me.jie65535.jcf.model.fingerprint.*
import me.jie65535.jcf.model.fingerprint.request.*
import me.jie65535.jcf.model.game.*
import me.jie65535.jcf.model.minecraft.*
import me.jie65535.jcf.model.minecraft.modloader.*
import me.jie65535.jcf.util.Date
@OptIn(ExperimentalSerializationApi::class)
object CurseClient {
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
serializersModule
}
private val http = HttpClient(OkHttp) {
install(HttpTimeout) {
this.requestTimeoutMillis = 30_0000
this.connectTimeoutMillis = 30_0000
this.socketTimeoutMillis = 30_0000
}
defaultRequest {
url.protocol = URLProtocol.HTTPS
url.host = "addons-ecs.forgesvc.net"
header("accept", "*/*")
}
}
suspend fun getGames(supportsAddons: Boolean = false): List<Game> {
return json.decodeFromString(
http.get("api/v2/game") {
parameter("supportsAddons", supportsAddons.toString())
}
)
}
suspend fun getGame(gameId: Int): Game {
return json.decodeFromString(http.get("api/v2/game/$gameId"))
}
suspend fun getGameDatabaseTimestamp(): Date {
return http.get("api/v2/game/timestamp")
}
suspend fun getAddon(projectId: Int): Addon {
return json.decodeFromString(http.get("api/v2/addon/$projectId"))
}
suspend fun getAddons(projectIds: Collection<Int>): List<Addon> {
return json.decodeFromString(
http.post("api/v2/addon") {
contentType(ContentType.Application.Json)
body = json.encodeToString(projectIds)
}
)
}
suspend fun getAddons(vararg projectIds: Int): List<Addon> = getAddons(projectIds.toList())
suspend fun getAddonDescription(projectId: Int): String {
return http.get("api/v2/addon/$projectId/description")
}
suspend fun getAddonFiles(projectId: Int): List<AddonFile> {
return json.decodeFromString(http.get("api/v2/addon/$projectId/files"))
}
suspend fun getAddonFiles(keys: Collection<Int>): Map<Int, List<AddonFile>> {
return json.decodeFromString(
http.post("api/v2/addon/files") {
contentType(ContentType.Application.Json)
body = json.encodeToString(keys)
}
)
}
suspend fun getAddonFiles(vararg keys: Int): Map<Int, List<AddonFile>> = getAddonFiles(keys.toList())
suspend fun getAddonFileDownloadUrl(projectId: Int, fileId: Int): String {
return http.get("api/v2/addon/$projectId/file/$fileId/download-url")
}
suspend fun getAddonFile(projectId: Int, fileId: Int): AddonFile {
return json.decodeFromString(http.get("api/v2/addon/$projectId/file/$fileId"))
}
suspend fun searchAddons(
gameId: Int,
sectionId: Int = -1,
categoryId: Int = -1,
sort: AddonSortMethod = AddonSortMethod.FEATURED,
sortDescending: Boolean = true,
gameVersion: String? = null,
index: Int = 0,
pageSize: Int = 10,
searchFilter: String? = null
): List<Addon> {
return json.decodeFromString(
http.get("api/v2/addon/search") {
parameter("gameId", gameId)
parameter("sectionId", sectionId)
parameter("categoryId", categoryId)
parameter("gameVersion", gameVersion)
parameter("index", index)
parameter("pageSize", pageSize)
parameter("searchFilter", searchFilter)
parameter("sort", sort)
parameter("sortDescending", sortDescending)
}
)
}
suspend fun getFeaturedAddons(
gameId: Int,
featuredCount: Int = 6,
popularCount: Int = 14,
updatedCount: Int = 14,
excludedAddons: Collection<Int> = listOf()
): Map<FeaturedAddonType, List<Addon>> {
return json.decodeFromString(
http.post("api/v2/addon/featured") {
contentType(ContentType.Application.Json)
body = json.encodeToString(FeaturedAddonsRequest(gameId, featuredCount, popularCount, updatedCount, excludedAddons))
}
)
}
suspend fun getFeaturedAddons(
gameId: Int,
featuredCount: Int = 6,
popularCount: Int = 14,
updatedCount: Int = 14,
vararg excludedAddons: Int
): Map<FeaturedAddonType, List<Addon>> = getFeaturedAddons(gameId, featuredCount, popularCount, updatedCount, excludedAddons.toList())
suspend fun getCategory(categoryId: Int): Category {
return json.decodeFromString(http.get("api/v2/category/$categoryId"))
}
suspend fun getCategory(slug: String): List<Category> {
return json.decodeFromString(
http.get("api/v2/category") {
parameter("slug", slug)
}
)
}
suspend fun getCategorySection(sectionId: Int): List<Category> {
return json.decodeFromString(http.get("api/v2/category/section/$sectionId"))
}
suspend fun getCategories(): List<Category> {
return json.decodeFromString(http.get("api/v2/category"))
}
suspend fun getCategoryDatabaseTimestamp(): Date {
return http.get("api/v2/category/timestamp")
}
suspend fun getFingerprintMatches(fingerprints: Collection<Long>): FingerprintMatchResult {
return json.decodeFromString(
http.post("api/v2/fingerprint") {
contentType(ContentType.Application.Json)
body = json.encodeToString(fingerprints)
}
)
}
suspend fun getFingerprintMatches(vararg fingerprints: Long): FingerprintMatchResult = getFingerprintMatches(fingerprints.toList())
suspend fun getFuzzyFingerprintMatches(gameId: Int, fingerprints: List<FolderFingerprint>): List<FuzzyFingerprintMatch> {
return json.decodeFromString(
http.post("api/v2/fingerprint/fuzzy") {
contentType(ContentType.Application.Json)
body = json.encodeToString(FuzzyMatchesRequest(gameId, fingerprints))
}
)
}
suspend fun getModloader(key: String): ModloaderVersion {
return json.decodeFromString(http.get("api/v2/minecraft/modloader/$key"))
}
suspend fun getModloaders(): List<ModloaderIndex> {
return json.decodeFromString(http.get("api/v2/minecraft/modloader"))
}
suspend fun getModloaders(gameVersion: String): List<ModloaderIndex> {
return json.decodeFromString(
http.get("api/v2/minecraft/modloader") {
parameter("version", gameVersion)
}
)
}
suspend fun getModloadersDatabaseTimestamp(): Date {
return http.get("api/v2/minecraft/modloader/timestamp")
}
suspend fun getMinecraftVersions(): List<MinecraftVersion> {
return json.decodeFromString(http.get("api/v2/minecraft/version"))
}
suspend fun getMinecraftVersion(gameVersion: String): MinecraftVersion {
return json.decodeFromString(http.get("api/v2/minecraft/version/$gameVersion"))
}
suspend fun getMinecraftVersionsDatabaseTimestamp(): Date {
return http.get("api/v2/minecraft/version/timestamp")
}
suspend fun getAddonFileChangeLog(addonId: Int, fileId: Int): String {
return http.get("api/v2/addon/${addonId}/file/${fileId}/changelog")
}
}

View File

@@ -1,30 +0,0 @@
package me.jie65535.jcf
import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register
import net.mamoe.mirai.console.command.CommandManager.INSTANCE.unregister
import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
import net.mamoe.mirai.utils.info
object JCurseforge : KotlinPlugin(
JvmPluginDescription(
id = "me.jie65535.jcf",
name = "J Curseforge Util",
version = "0.1.0",
) {
author("jie65535")
info("""
MC Curseforge Util
https://github.com/jie65535/mirai-console-jcf-plugin
""".trimIndent())
}
) {
override fun onEnable() {
logger.info { "Plugin loaded" }
JcfCommand.register()
}
override fun onDisable() {
JcfCommand.unregister()
}
}

View File

@@ -1,53 +0,0 @@
package me.jie65535.jcf
import net.mamoe.mirai.console.command.CommandSender
import net.mamoe.mirai.console.command.CompositeCommand
import net.mamoe.mirai.console.command.UserCommandSender
import net.mamoe.mirai.console.plugin.author
import net.mamoe.mirai.console.plugin.info
import net.mamoe.mirai.console.plugin.name
import net.mamoe.mirai.console.plugin.version
object JcfCommand : CompositeCommand(
JCurseforge, "jcf",
description = "Curseforge Util"
) {
@SubCommand
@Description("帮助")
suspend fun CommandSender.help() {
sendMessage("${JCurseforge.name} by ${JCurseforge.author} ${JCurseforge.version}\n" +
"${JCurseforge.info}\n" + usage)
}
@SubCommand("ss")
@Description("直接搜索")
suspend fun UserCommandSender.search(filter: String) {
MinecraftService.search(this, MinecraftService.ALL, filter)
}
@SubCommand("ssmod")
@Description("搜索模组")
suspend fun UserCommandSender.searchMods(filter: String) {
MinecraftService.search(this, MinecraftService.SECTION_ID_MODES, filter)
}
@SubCommand("sspack")
@Description("搜索整合包")
suspend fun UserCommandSender.searchModPacks(filter: String) {
MinecraftService.search(this, MinecraftService.SECTION_ID_MODE_PACKS, filter)
}
@SubCommand("ssres")
@Description("搜索资源包")
suspend fun UserCommandSender.searchResourcePacks(filter: String) {
MinecraftService.search(this, MinecraftService.SECTION_ID_RESOURCE_PACKS, filter)
}
// 可能用不上,先注释掉,有需要再说
// @SubCommand("ssworld")
// @Description("搜索存档")
// suspend fun UserCommandSender.searchWorlds(filter: String) {
// MinecraftService.search(this, MinecraftService.SECTION_ID_WORLDS, filter)
// }
}

View File

@@ -1,88 +0,0 @@
package me.jie65535.jcf
import me.jie65535.jcf.model.addon.Addon
import me.jie65535.jcf.model.addon.file.AddonFile
import me.jie65535.jcf.util.HttpUtil
import net.mamoe.mirai.contact.Contact
import net.mamoe.mirai.message.data.*
import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
import java.text.DecimalFormat
import kotlin.math.min
object MessageHandler {
fun parseSearchResult(addons: List<Addon>, builder: ForwardMessageBuilder, contact: Contact) {
for ((index, addon) in addons.withIndex()) {
builder.add(contact.bot.id, index.toString(),
PlainText("""
$index | [${addon.name}] by ${addon.authors[0].name}
${formatCount(addon.downloadCount)} Downloads Updated ${addon.dateModified.toLocalDate()}
${addon.summary}
${addon.websiteUrl}
""".trimIndent()))
}
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun parseAddon(addon: Addon, contact: Contact): ForwardMessageBuilder {
val builder = ForwardMessageBuilder(contact)
.add(contact.bot, loadImage(addon.attachments.find{ it.isDefault }!!.thumbnailUrl, contact))
.add(contact.bot, PlainText("""
${addon.name}
作者:${addon.authors[0].name}
概括:${addon.summary}
项目ID${addon.id}
创建时间:${addon.dateCreated.toLocalDate()}
最近更新:${addon.dateModified.toLocalDate()}
总下载:${addon.downloadCount.toLong()}
主页:${addon.websiteUrl}
""".trimIndent()))
if (addon.latestFiles.isNotEmpty()) {
builder.add(contact.bot, PlainText("最新文件列表:"))
parseAddonFiles(addon.latestFiles, builder, contact)
}
return builder
}
fun parseAddonFiles(addonFiles: Collection<AddonFile>, builder: ForwardMessageBuilder, contact: Contact) {
for ((index, file) in addonFiles.withIndex()) {
builder.add(contact.bot.id, index.toString(), PlainText("""
$index | ${file.displayName} [${file.releaseType}] [${file.fileDate.toLocalDate()}]
${file.downloadUrl}
""".trimIndent()))
}
}
private const val ONE_GRP_SIZE = 5000
private const val ONE_MSG_SIZE = 500
suspend fun sendLargeMessage(contact: Contact, message: String) {
for (g in message.indices step ONE_GRP_SIZE) {
val builder = ForwardMessageBuilder(contact)
for (i in g until g + min(ONE_GRP_SIZE, message.length-g) step ONE_MSG_SIZE) {
builder.add(contact.bot, PlainText(message.subSequence(i, i+(min(ONE_MSG_SIZE, message.length-i)))))
}
contact.sendMessage(builder.build())
}
}
private val singleDecimalFormat = DecimalFormat("0.#")
private fun formatCount(count: Double): String = when {
count < 1000000 -> singleDecimalFormat.format(count / 1000) + "K"
count < 1000000000 -> singleDecimalFormat.format(count / 1000000) + "M"
else -> count.toString()
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun loadImage(url: String, contact: Contact): Image {
val imgFileName = url.substringAfterLast("/")
val file = JCurseforge.resolveDataFile("cache/$imgFileName")
val res = if (file.exists()) {
file.readBytes().toExternalResource()
} else {
HttpUtil.downloadImage(url, file).toExternalResource()
}
val image = contact.uploadImage(res)
res.close()
return image
}
}

View File

@@ -1,157 +0,0 @@
package me.jie65535.jcf
import kotlinx.coroutines.TimeoutCancellationException
import me.jie65535.jcf.model.addon.Addon
import me.jie65535.jcf.model.addon.AddonSortMethod
import me.jie65535.jcf.model.addon.file.AddonFile
import net.mamoe.mirai.console.command.UserCommandSender
import net.mamoe.mirai.event.events.MessageEvent
import net.mamoe.mirai.event.nextEventAsync
import net.mamoe.mirai.message.data.ForwardMessageBuilder
import net.mamoe.mirai.message.data.PlainText
import net.mamoe.mirai.message.data.content
import net.mamoe.mirai.utils.MiraiExperimentalApi
import java.lang.Exception
import java.util.regex.Pattern
@OptIn(MiraiExperimentalApi::class)
object MinecraftService {
private val curseClient = CurseClient
private const val GAME_ID_MINECRAFT = 432
const val SECTION_ID_MODES = 6
const val SECTION_ID_RESOURCE_PACKS = 12
const val SECTION_ID_WORLDS = 17
const val SECTION_ID_MODE_PACKS = 4471
const val ALL = -1
private const val WAIT_REPLY_TIMEOUT_MS = 60000L
private const val PAGE_SIZE = 10
private const val NEXT_PAGE_KEYWORD = "n"
private const val SHOW_FILES_KEYWORD = "files"
suspend fun search(sender: UserCommandSender, sectionId: Int, filter: String) {
val addon: Addon
var pageIndex = 0
while (true) {
val searchResult = doSearch(sectionId, filter, pageIndex++, PAGE_SIZE)
val hasNextPage = searchResult.size == PAGE_SIZE
if (searchResult.isEmpty()) {
sender.sendMessage("未搜索到结果,请更换关键字重试。")
return
} else if (searchResult.size == 1) {
addon = searchResult[0]
break
}
val builder = ForwardMessageBuilder(sender.subject)
builder.add(sender.bot, PlainText("${WAIT_REPLY_TIMEOUT_MS/1000}秒内回复编号查看"))
MessageHandler.parseSearchResult(searchResult, builder, sender.subject)
if (hasNextPage) builder.add(sender.bot, PlainText("回复[$NEXT_PAGE_KEYWORD]下一页"))
sender.sendMessage(builder.build())
try {
val nextEvent = sender.nextEventAsync<MessageEvent>(
WAIT_REPLY_TIMEOUT_MS,
coroutineContext = sender.coroutineContext
) { it.sender == sender.user } .await()
if (hasNextPage && nextEvent.message.contentEquals(NEXT_PAGE_KEYWORD, true))
continue
addon = searchResult[nextEvent.message.content.toInt()]
break
} catch (e: TimeoutCancellationException) {
sender.sendMessage("等待回复超时,请重新查询。")
} catch (e: NumberFormatException) {
sender.sendMessage("请正确回复序号,此次查询已取消,请重新查询。")
} catch (e: IndexOutOfBoundsException) {
sender.sendMessage("请回复正确的序号,此次查询已取消,请重新查询。")
} catch (e: Exception) {
sender.sendMessage("内部发生异常,此次查询已取消,请重新查询。")
throw e
}
return
}
// addon handle
showAddon(sender, addon)
}
private suspend fun showAddon(sender: UserCommandSender, addon: Addon) {
val builder = MessageHandler.parseAddon(addon, sender.subject)
if (addon.latestFiles.isNotEmpty())
builder.add(sender.bot, PlainText("${WAIT_REPLY_TIMEOUT_MS/1000}秒内回复[${SHOW_FILES_KEYWORD}]查看所有文件,回复文件序号查看更新日志"))
sender.sendMessage(builder.build())
try {
val nextEvent = sender.nextEventAsync<MessageEvent>(
WAIT_REPLY_TIMEOUT_MS,
coroutineContext = sender.coroutineContext
) { it.sender == sender.user } .await()
if (nextEvent.message.contentEquals(SHOW_FILES_KEYWORD, true)) {
showAllFiles(sender, addon)
} else {
val file = addon.latestFiles[nextEvent.message.content.toInt()]
showChangedLog(sender, addon.id, file)
}
} catch (e: TimeoutCancellationException) {
sender.sendMessage("等待回复超时,请重新查询。")
} catch (e: NumberFormatException) {
sender.sendMessage("请正确回复序号,此次查询已取消,请重新查询。")
} catch (e: IndexOutOfBoundsException) {
sender.sendMessage("请回复正确的序号,此次查询已取消,请重新查询。")
} catch (e: Exception) {
sender.sendMessage("内部发生异常,此次查询已取消,请重新查询。")
throw e
}
}
private suspend fun showAllFiles(sender: UserCommandSender, addon: Addon) {
val files = CurseClient.getAddonFiles(addon.id).sortedByDescending { f -> f.fileDate }
if (files.isEmpty()) {
sender.sendMessage("没有任何文件 :(")
return
}
val builder = ForwardMessageBuilder(sender.subject)
builder.add(sender.bot, PlainText("${WAIT_REPLY_TIMEOUT_MS/1000}秒内回复文件序号查看更新日志"))
MessageHandler.parseAddonFiles(files, builder, sender.subject)
sender.sendMessage(builder.build())
try { // cv大法好回复功能有功夫再封装先复制粘贴用着 XD
val nextEvent = sender.nextEventAsync<MessageEvent>(
WAIT_REPLY_TIMEOUT_MS,
coroutineContext = sender.coroutineContext
) { it.sender == sender.user } .await()
val file = files[nextEvent.message.content.toInt()]
showChangedLog(sender, addon.id, file)
} catch (e: TimeoutCancellationException) {
sender.sendMessage("等待回复超时,请重新查询。")
} catch (e: NumberFormatException) {
sender.sendMessage("请正确回复序号,此次查询已取消,请重新查询。")
} catch (e: IndexOutOfBoundsException) {
sender.sendMessage("请回复正确的序号,此次查询已取消,请重新查询。")
}
}
private val HTMLPattern = Pattern.compile("<[^>]+>", Pattern.CASE_INSENSITIVE)
private suspend fun showChangedLog(sender: UserCommandSender, addonId: Int, addonFile: AddonFile) {
val changeLogHTML = curseClient.getAddonFileChangeLog(addonId, addonFile.id)
val changeLog = HTMLPattern.matcher(changeLogHTML).replaceAll("")
MessageHandler.sendLargeMessage(sender.subject, changeLog)
}
private suspend fun doSearch(sectionId: Int, filter: String, pageIndex: Int, pageSize: Int): List<Addon> {
return curseClient.searchAddons(
gameId = GAME_ID_MINECRAFT,
sectionId = sectionId,
categoryId = -1,
sort = AddonSortMethod.POPULARITY,
sortDescending = true,
gameVersion = null,
index = pageIndex,
pageSize = pageSize,
searchFilter = filter,
)
}
}

View File

@@ -1,52 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.addon.file.AddonFile
import me.jie65535.jcf.model.addon.file.AddonFileLatestForGameVersion
import me.jie65535.jcf.model.category.CategorySection
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class Addon(
val id: Int,
val name: String,
val authors: List<AddonAuthor>,
val attachments: List<AddonAttachment>,
val websiteUrl: String,
val gameId: Int,
val summary: String,
val defaultFileId: Int,
val downloadCount: Double,
val latestFiles: List<AddonFile>,
val categories: List<AddonCategory>,
val status: AddonStatus,
val primaryCategoryId: Int,
val categorySection: CategorySection,
val slug: String,
val gameVersionLatestFiles: List<AddonFileLatestForGameVersion>,
val isFeatured: Boolean,
val popularityScore: Double,
val gamePopularityRank: Int,
val primaryLanguage: String,
val gameSlug: String,
val gameName: String,
val portalName: String,
@Serializable(with = DateSerializer::class)
val dateModified: Date,
@Serializable(with = DateSerializer::class)
val dateCreated: Date,
@Serializable(with = DateSerializer::class)
val dateReleased: Date,
val isAvailable: Boolean,
@SerialName("isExperiemental")
val isExperimental: Boolean
)

View File

@@ -1,22 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
@Serializable
data class AddonAttachment(
val id: Int,
val projectId: Int,
val description: String,
val isDefault: Boolean,
val thumbnailUrl: String,
val title: String,
val url: String,
val status: AddonAttachmentStatus
)

View File

@@ -1,24 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonAttachmentStatus.Ser::class)
enum class AddonAttachmentStatus {
NORMAL,
DELETED,
UPLOADING,
BANNED,
PENDING_MODERATION;
internal object Ser : EnumIntSerializer<AddonAttachmentStatus>("$MODEL_PACKAGE.addon.AttachmentStatus", values())
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
@Serializable
data class AddonAuthor(
val name: String,
val url: String,
val projectId: Int,
val id: Int,
val projectTitleId: Int?,
val projectTitleTitle: String?,
val userId: Int,
val twitchId: Int?
)

View File

@@ -1,23 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
@Serializable
data class AddonCategory(
val categoryId: Int,
val name: String,
val url: String,
val avatarUrl: String?,
val parentId: Int?,
val rootId: Int?,
val projectId: Int,
val avatarId: Int?,
val gameId: Int
)

View File

@@ -1,24 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonPackageType.Ser::class)
enum class AddonPackageType {
FOLDER,
CTOC,
SINGLE_FILE,
CMOD2,
MOD_PACK,
MOD;
internal object Ser : EnumIntSerializer<AddonPackageType>("$MODEL_PACKAGE.addon.AddonPackageType", values())
}

View File

@@ -1,17 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.addon.file.AddonFile
@Serializable
data class AddonRepositoryMatch(
val id: Int,
val latestFiles: List<AddonFile>
)

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonRestrictFileAccess.Ser::class)
enum class AddonRestrictFileAccess {
NONE,
ALPHA,
ALPHA_AND_BETA;
internal object Ser : EnumIntSerializer<AddonRestrictFileAccess>("$MODEL_PACKAGE.addon.AddonRestrictFileAccess", values())
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
/**
* 附加排序方法
*/
enum class AddonSortMethod {
/**
* 精选
*/
FEATURED,
/**
* 人气
*/
POPULARITY,
/**
* 最后更新时间
*/
LAST_UPDATED,
/**
* 名称
*/
NAME,
/**
* 作者
*/
AUTHOR,
/**
* 总下载数
*/
TOTAL_DOWNLOADS,
/**
* 类别
*/
CATEGORY,
/**
* 游戏版本
*/
GAME_VERSION
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonStatus.Ser::class)
enum class AddonStatus {
NEW,
CHANGES_REQUIRED,
UNDER_SOFT_REVIEW,
APPROVED,
REJECTED,
CHANGES_MADE,
INACTIVE,
ABANDONED,
DELETED,
UNDER_REVIEW;
internal object Ser : EnumIntSerializer<AddonStatus>("$MODEL_PACKAGE.addon.AddonStatus", values())
}

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class FeaturedAddonType {
@SerialName("Featured")
FEATURED,
@SerialName("Popular")
POPULAR,
@SerialName("RecentlyUpdated")
RECENTLY_UPDATED;
}

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class SortableGameVersion(
val gameVersionPadded: String,
val gameVersion: String,
@Serializable(with = DateSerializer::class)
val gameVersionReleaseDate: Date,
val gameVersionName: String
)

View File

@@ -1,60 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.addon.AddonPackageType
import me.jie65535.jcf.model.addon.AddonRestrictFileAccess
import me.jie65535.jcf.model.addon.AddonStatus
import me.jie65535.jcf.model.addon.SortableGameVersion
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class AddonFile(
val id: Int,
val displayName: String,
val fileName: String,
@Serializable(with = DateSerializer::class)
val fileDate: Date,
val fileLength: Long,
val releaseType: AddonFileReleaseType,
val fileStatus: AddonFileStatus,
val downloadUrl: String,
val isAlternate: Boolean,
val alternateFileId: Int,
val dependencies: List<AddonFileDependency>,
val isAvailable: Boolean,
val modules: List<AddonFileModule>,
val packageFingerprint: Long,
val gameVersion: List<String>,
val sortableGameVersion: List<SortableGameVersion>? = null,
val installMetadata: String?,
val changelog: String? = null,
val hasInstallScript: Boolean,
val isCompatibleWithClient: Boolean? = null,
val categorySectionPackageType: AddonPackageType? = null,
val restrictProjectFileAccess: AddonRestrictFileAccess? = null,
val projectStatus: AddonStatus? = null,
val renderCacheId: Int? = null,
val fileLegacyMappingId: Int? = null,
val projectId: Int? = null,
val parentProjectFileId: Int? = null,
val parentFileLegacyMappingId: Int? = null,
val fileTypeId: Int? = null,
val exposeAsAlternative: Boolean? = null,
val packageFingerprintId: Int? = null,
@Serializable(with = DateSerializer::class)
val gameVersionDateReleased: Date?,
val gameVersionMappingId: Int? = null,
val gameVersionId: Int? = null,
val gameId: Int? = null,
val isServerPack: Boolean = false,
val serverPackFileId: Int?,
val gameVersionFlavor: String?
)

View File

@@ -1,18 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.Serializable
@Serializable
data class AddonFileDependency(
val id: Int? = null,
val addonId: Int,
val type: AddonFileRelationType,
val fileId: Int? = null
)

View File

@@ -1,23 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonFileFingerprintType.Ser::class)
enum class AddonFileFingerprintType {
PACKAGE,
MODULE,
MAIN_MODULE,
FILE,
REFERRENCED_FILE;
internal object Ser : EnumIntSerializer<AddonFileFingerprintType>("$MODEL_PACKAGE.addon.file.AddonFileFingerprintType", values())
}

View File

@@ -1,19 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.Serializable
@Serializable
data class AddonFileLatestForGameVersion(
val gameVersion: String,
val projectFileId: Int,
val projectFileName: String,
val fileType: AddonFileReleaseType,
val gameVersionFlavor: String?
)

View File

@@ -1,19 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AddonFileModule(
@SerialName("foldername")
val folderName: String,
val fingerprint: Long,
val type: AddonFileFingerprintType? = null
)

View File

@@ -1,24 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonFileRelationType.Ser::class)
enum class AddonFileRelationType {
EMBEDDED_LIBRARY,
OPTIONAL_DEPENDENCY,
REQUIRED_DEPENDENCY,
TOOL,
INCOMPATIBLE,
INCLUDE;
internal object Ser : EnumIntSerializer<AddonFileRelationType>("$MODEL_PACKAGE.addon.file.AddonFileRelationType", values())
}

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonFileReleaseType.Ser::class)
enum class AddonFileReleaseType {
RELEASE,
BETA,
ALPHA;
internal object Ser : EnumIntSerializer<AddonFileReleaseType>("$MODEL_PACKAGE.addon.file.AddonFileReleaseType", values())
}

View File

@@ -1,33 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.file
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = AddonFileStatus.Ser::class)
enum class AddonFileStatus {
PROCESSING,
CHANGES_REQUIRED,
UNDER_REVIEW,
APPROVED,
REJECTED,
MALWARE_DETECTED,
DELETED,
ARCHIVED,
TESTING,
RELEASED,
READY_FOR_REVIEW,
DEPRECATED,
BAKING,
AWAITING_FOR_PUBLISHING,
FAILED_PUBLISHING;
internal object Ser : EnumIntSerializer<AddonFileStatus>("$MODEL_PACKAGE.addon.file.AddonFileStatus", values())
}

View File

@@ -1,18 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.addon.request
import kotlinx.serialization.Serializable
@Serializable
data class FeaturedAddonsRequest(
val gameId: Int,
val featuredCount: Int,
val popularCount: Int,
val updatedCount: Int,
val excludedAddons: Collection<Int>
)

View File

@@ -1,25 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.category
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class Category(
val id: Int,
val name: String,
val slug: String,
val avatarUrl: String?,
@Serializable(with = DateSerializer::class)
val dateModified: Date,
val parentGameCategoryId: Int?,
val rootGameCategoryId: Int?,
val gameId: Int
)

View File

@@ -1,23 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.category
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.addon.AddonPackageType
@Serializable
data class CategorySection(
val extraIncludePattern: String?,
val gameCategoryId: Int,
val gameId: Int,
val id: Int,
val initialInclusionPattern: String,
val name: String,
val packageType: AddonPackageType,
val path: String
)

View File

@@ -1,18 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.fingerprint
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.addon.file.AddonFile
@Serializable
data class FingerprintMatch(
val id: Int,
val file: AddonFile,
val latestFiles: List<AddonFile>
)

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.fingerprint
import kotlinx.serialization.Serializable
@Serializable
data class FingerprintMatchResult(
val isCacheBuilt: Boolean,
val exactMatches: List<FingerprintMatch>,
val exactFingerprints: List<Long>,
val partialMatches: List<FingerprintMatch>,
val partialMatchFingerprints: Map<String, List<Long>>,
val installedFingerprints: List<Long>,
val unmatchedFingerprints: List<Long>
)

View File

@@ -1,18 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.fingerprint
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FolderFingerprint(
@SerialName("foldername")
val folderName: String,
val fingerprints: List<Long>
)

View File

@@ -1,19 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.fingerprint
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.addon.file.AddonFile
@Serializable
data class FuzzyFingerprintMatch(
val id: Int,
val file: AddonFile,
val latestFiles: List<AddonFile>,
val fingerprints: List<Long>
)

View File

@@ -1,17 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.fingerprint.request
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.fingerprint.FolderFingerprint
@Serializable
data class FuzzyMatchesRequest(
val gameId: Int,
val fingerprints: List<FolderFingerprint>
)

View File

@@ -1,41 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.category.CategorySection
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class Game(
val id: Int,
val name: String,
val slug: String,
@Serializable(with = DateSerializer::class)
val dateModified: Date,
val gameFiles: List<GameFile>,
val gameDetectionHints: List<GameDetectionHint>,
val fileParsingRules: List<GameFileParsingRule>,
val categorySections: List<CategorySection>,
val maxFreeStorage: Long,
val maxPremiumStorage: Long,
val maxFileSize: Long,
val addonSettingsFolderFilter: String?,
val addonSettingsStartingFolder: String?,
val addonSettingsFileFilter: String?,
val addonSettingsFileRemovalFilter: String?,
val supportsAddons: Boolean,
val supportsPartnerAddons: Boolean,
@Serializable(with = SupportedClientConfiguration.Ser::class)
val supportedClientConfiguration: Set<SupportedClientConfiguration>,
val supportsNotifications: Boolean,
val profilerAddonId: Int,
val twitchGameId: Int,
val clientGameSettingsId: Int
)

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
@Serializable
data class GameDetectionHint(
val id: Int,
val hintType: GameDetectionHintType,
val hintPath: String,
val hintKey: String?,
@Serializable(with = GameDetectionHintOption.Ser::class)
val hintOptions: Set<GameDetectionHintOption>,
val gameId: Int
)

View File

@@ -1,19 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import me.jie65535.jcf.util.internal.FlagSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
enum class GameDetectionHintOption {
NONE,
INCLUDE_SUB_FOLDERS,
FOLDER_ONLY;
internal object Ser : FlagSerializer<GameDetectionHintOption>("$MODEL_PACKAGE.game.GameDetectionHintOption", NONE to 0x1, INCLUDE_SUB_FOLDERS to 0x2, FOLDER_ONLY to 0x4)
}

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = GameDetectionHintType.Ser::class)
enum class GameDetectionHintType {
REGISTRY,
FILE_PATH;
internal object Ser : EnumIntSerializer<GameDetectionHintType>("$MODEL_PACKAGE.game.GameDetectionHintType", values())
}

View File

@@ -1,20 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
@Serializable
data class GameFile(
val id: Int,
val gameId: Int,
val isRequired: Boolean,
val fileName: String,
val fileType: GameFileType,
val platformType: GamePlatformType
)

View File

@@ -1,19 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
@Serializable
data class GameFileParsingRule(
val commentStripPattern: String,
val fileExtension: String,
val inclusionPattern: String,
val gameId: Int,
val id: Int
)

View File

@@ -1,22 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = GameFileType.Ser::class)
enum class GameFileType {
GENERIC,
GAME,
LAUNCHER,
PROFILER_LOCK_CHECK;
internal object Ser : EnumIntSerializer<GameFileType>("$MODEL_PACKAGE.game.GameFileType", values())
}

View File

@@ -1,23 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = GamePlatformType.Ser::class)
enum class GamePlatformType {
GENERIC,
WINDOWS_32,
WINDOWS_64,
WINDOWS,
OSX;
internal object Ser : EnumIntSerializer<GamePlatformType>("$MODEL_PACKAGE.game.GamePlatformType", values())
}

View File

@@ -1,19 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.game
import me.jie65535.jcf.util.internal.FlagSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
enum class SupportedClientConfiguration {
DEBUG,
BETA,
RELEASE;
internal object Ser : FlagSerializer<SupportedClientConfiguration>("$MODEL_PACKAGE.game.SupportedClientConfiguration", DEBUG to 0x1, BETA to 0x2, RELEASE to 0x4)
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.minecraft
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class MinecraftVersion(
val id: Int,
val gameVersionId: Int,
val versionString: String,
val jarDownloadUrl: String,
val jsonDownloadUrl: String,
val approved: Boolean,
@Serializable(with = DateSerializer::class)
val dateModified: Date,
val gameVersionTypeId: Int,
val gameVersionStatus: MinecraftVersionStatus,
val gameVersionTypeStatus: MinecraftVersionTypeStatus
)

View File

@@ -1,21 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.minecraft
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = MinecraftVersionStatus.Ser::class)
enum class MinecraftVersionStatus {
APPROVED,
DELETED,
NEW;
internal object Ser : EnumIntSerializer<MinecraftVersionStatus>("$MODEL_PACKAGE.game.MinecraftVersionStatus", values())
}

View File

@@ -1,20 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.minecraft
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = MinecraftVersionTypeStatus.Ser::class)
enum class MinecraftVersionTypeStatus {
NORMAL,
DELETED;
internal object Ser : EnumIntSerializer<MinecraftVersionTypeStatus>("$MODEL_PACKAGE.game.MinecraftVersionTypeStatus", values())
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.minecraft.modloader
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class ModloaderIndex(
val name: String,
val gameVersion: String,
val latest: Boolean,
val recommended: Boolean,
@Serializable(with = DateSerializer::class)
val dateModified: Date
)

View File

@@ -1,22 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.minecraft.modloader
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = ModloaderInstallMethod.Ser::class)
enum class ModloaderInstallMethod {
ANY,
FORGE,
CAULDRON,
LITE_LOADER;
internal object Ser : EnumIntSerializer<ModloaderInstallMethod>("$MODEL_PACKAGE.game.ModloaderInstallMethod", values())
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.minecraft.modloader
import kotlinx.serialization.Serializable
import me.jie65535.jcf.util.internal.EnumIntSerializer
import me.jie65535.jcf.util.internal.MODEL_PACKAGE
@Serializable(with = ModloaderType.Ser::class)
enum class ModloaderType {
ANY,
FORGE,
CAULDRON,
LITE_LOADER;
internal object Ser : EnumIntSerializer<ModloaderType>("$MODEL_PACKAGE.game.ModloaderType", values(), 0)
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.model.minecraft.modloader
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.jie65535.jcf.model.minecraft.MinecraftVersionStatus
import me.jie65535.jcf.model.minecraft.MinecraftVersionTypeStatus
import me.jie65535.jcf.util.Date
import me.jie65535.jcf.util.internal.DateSerializer
@Serializable
data class ModloaderVersion(
val id: Int,
val gameVersionId: Int,
val minecraftGameVersionId: Int,
val forgeVersion: String,
val name: String,
val type: ModloaderType,
val downloadUrl: String,
@SerialName("filename")
val fileName: String,
val installMethod: ModloaderInstallMethod,
val latest: Boolean,
val recommended: Boolean,
val approved: Boolean,
@Serializable(with = DateSerializer::class)
val dateModified: Date,
val mavenVersionString: String,
val versionJson: String,
val librariesInstallLocation: String,
val minecraftVersion: String,
val additionalFilesJson: String,
val modLoaderGameVersionId: Int,
val modLoaderGameVersionTypeId: Int,
val modLoaderGameVersionStatus: MinecraftVersionStatus,
val modLoaderGameVersionTypeStatus: MinecraftVersionTypeStatus,
val mcGameVersionId: Int,
val mcGameVersionTypeId: Int,
val mcGameVersionStatus: MinecraftVersionStatus,
val mcGameVersionTypeStatus: MinecraftVersionTypeStatus,
val installProfileJson: String
)

View File

@@ -1,5 +0,0 @@
package me.jie65535.jcf.util
import java.time.OffsetDateTime
typealias Date = OffsetDateTime

View File

@@ -1,26 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.util.internal
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import me.jie65535.jcf.util.Date
import java.time.OffsetDateTime
internal object DateSerializer : KSerializer<Date> {
override val descriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): Date {
return OffsetDateTime.parse(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: Date) {
encoder.encodeString(value.toString())
}
}

View File

@@ -1,25 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.util.internal
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
internal open class EnumIntSerializer<T>(private val serialName: String, private val values: Array<T>, private val startFrom: Int = 1) : KSerializer<T> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(serialName, PrimitiveKind.INT)
override fun deserialize(decoder: Decoder): T {
val value = decoder.decodeInt()
return values[value - startFrom] ?: throw NoSuchElementException("Can't find $serialName with key $value")
}
override fun serialize(encoder: Encoder, value: T) {
encoder.encodeInt(values.indexOf(value) + startFrom)
}
}

View File

@@ -1,36 +0,0 @@
/*
* Copyright © 2020, PearX Team
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package me.jie65535.jcf.util.internal
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
internal open class FlagSerializer<T : Enum<T>>(serialName: String, private val map: Map<T, Int>) : KSerializer<Set<T>> {
constructor(serialName: String, vararg pairs: Pair<T, Int>) : this(serialName, mapOf(*pairs))
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(serialName, PrimitiveKind.INT)
override fun serialize(encoder: Encoder, value: Set<T>) {
var bits = 0
for(flag in value) {
bits = bits or (map[flag] ?: error(""))
}
encoder.encodeInt(bits)
}
override fun deserialize(decoder: Decoder): Set<T> {
val set = mutableSetOf<T>()
val bits = decoder.decodeInt()
for((flag, shift) in map) {
if(bits and shift == shift)
set += flag
}
return set
}
}

View File

@@ -1,3 +0,0 @@
package me.jie65535.jcf.util.internal
internal const val MODEL_PACKAGE = "me.jie65535.jcf.model"

View File

@@ -0,0 +1,284 @@
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.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import top.jie65535.jcf.model.Category
import top.jie65535.jcf.model.file.File
import top.jie65535.jcf.model.game.Game
import top.jie65535.jcf.model.game.GameVersionType
import top.jie65535.jcf.model.mod.*
import top.jie65535.jcf.model.request.*
import top.jie65535.jcf.model.request.SortOrder.*
import top.jie65535.jcf.model.response.*
/**
* [Api docs](https://docs.curseforge.com/)
* @author jie65535
*/
@OptIn(ExperimentalSerializationApi::class)
class CurseforgeApi(apiKey: String) {
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
serializersModule
}
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.curseforge.com"
header("accept", "application/json")
header("x-api-key", apiKey)
}
}
//region - Game -
/**
* Get all games that are available to the provided API key.
*/
suspend fun getGames(index: Int? = null, pageSize: Int? = null): GetGamesResponse {
return json.decodeFromString(
http.get("/v1/games") {
parameter("index", index)
parameter("pageSize", pageSize)
}.body()
)
}
/**
* Get a single game. A private game is only accessible by its respective API key.
*/
suspend fun getGame(gameId: Int): Game {
return json.decodeFromString<GetGameResponse>(
http.get("/v1/games/$gameId").body()
).data
}
/**
* Get all available versions for each known version type of the specified game.
* A private game is only accessible to its respective API key.
*/
suspend fun getVersions(gameId: Int): Array<GameVersionsByType> {
return json.decodeFromString<GetVersionsResponse>(
http.get("/v1/games/$gameId/versions").body()
).data
}
/**
* Get all available version types of the specified game.
*
* A private game is only accessible to its respective API key.
*
* Currently, when creating games via the CurseForge Core Console,
* you are limited to a single game version type.
* This means that this endpoint is probably not useful in most cases
* and is relevant mostly when handling existing games that have
* multiple game versions such as World of Warcraft and Minecraft
* (e.g. 517 for wow_retail).
*/
suspend fun getVersionTypes(gameId: Int): Array<GameVersionType> {
return json.decodeFromString<GetVersionTypesResponse>(
http.get("/v1/games/$gameId/versions").body()
).data
}
//endregion
//region - Categories -
/**
* Get all available classes and categories of the specified game.
* Specify a game id for a list of all game categories,
* or a class id for a list of categories under that class.
*/
suspend fun getCategories(gameId: Int, classId: Int? = null): Array<Category> {
return json.decodeFromString<GetCategoriesResponse>(
http.get("/v1/categories") {
parameter("gameId", gameId)
parameter("classId", classId)
}.body()
).data
}
//endregion
//region - Mods -
/**
* Get all mods that match the search criteria.
* @param gameId Filter by game id.
* @param classId Filter by section id (discoverable via Categories)
* @param categoryId Filter by category id
* @param gameVersion Filter by game version string
* @param searchFilter Filter by free text search in the mod name and author
* @param sortField Filter by ModsSearchSortField enumeration
* @param sortOrder 'asc' if sort is in ascending order, 'desc' if sort is in descending order
* @param modLoaderType Filter only mods associated to a given modloader (Forge, Fabric ...). Must be coupled with gameVersion.
* @param gameVersionTypeId Filter only mods that contain files tagged with versions of the given gameVersionTypeId
* @param slug Filter by slug (coupled with classId will result in a unique result).
* @param index A zero based index of the first item to include in the response,
* @param pageSize The number of items to include in the response,
*/
suspend fun searchMods(
gameId: Int,
classId: Int? = null,
categoryId: Int? = null,
gameVersion: String? = null,
searchFilter: String? = null,
sortField: ModsSearchSortField? = null,
sortOrder: SortOrder? = null,
modLoaderType: ModLoaderType? = null,
gameVersionTypeId: Int? = null,
slug: String? = null,
index: Int? = null,
pageSize: Int? = null
): SearchModsResponse {
return json.decodeFromString(
http.get("/v1/mods/search") {
parameter("gameId", gameId)
parameter("classId", classId)
parameter("categoryId", categoryId)
parameter("gameVersion", gameVersion)
parameter("searchFilter", searchFilter)
parameter("sortField", sortField)
parameter("sortOrder", when(sortOrder){
ASC -> "asc"
DESC -> "desc"
null -> null
})
parameter("modLoaderType", modLoaderType)
parameter("gameVersionTypeId", gameVersionTypeId)
parameter("slug", slug)
parameter("index", index)
parameter("pageSize", pageSize)
}.body()
)
}
/**
* Get a single mod.
*/
suspend fun getMod(modId: Int): Mod {
return json.decodeFromString<GetModResponse>(
http.get("/v1/mods/$modId").body()
).data
}
/**
* Get a list of mods.
*/
suspend fun getMods(modIds: IntArray): Array<Mod> {
return json.decodeFromString<GetModsResponse>(
http.post("/v1/mods") {
headers.append("Content-Type", "application/json")
setBody(json.encodeToString(GetModsByIdsListRequestBody(modIds)))
}.body()
).data
}
/**
* Get a list of featured, popular and recently updated mods.
*/
suspend fun getFeaturedMods(
gameId: Int,
excludedModIds: IntArray = intArrayOf(),
gameVersionTypeId: Int? = null
): FeaturedModsResponse {
return json.decodeFromString<GetFeaturedModsResponse>(
http.get("/v1/mods/featured") {
headers.append("Content-Type", "application/json")
setBody(json.encodeToString(GetFeaturedModsRequestBody(gameId, excludedModIds, gameVersionTypeId)))
}.body()
).data
}
/**
* Get the full description of a mod in HTML format.
*/
suspend fun getModDescription(modId: Int): String {
return json.decodeFromString<StringResponse>(
http.get("/v1/mods/$modId/description").body()
).data
}
//endregion
//region - Files -
/**
* Get a single file of the specified mod.
*/
suspend fun getModFile(modId: Int, fileId: Int): File {
return json.decodeFromString<GetModFileResponse>(
http.get("/v1/mods/$modId/files/$fileId").body()
).data
}
/**
* Get all files of the specified mod.
*/
suspend fun getModFiles(
modId: Int,
gameVersion: String? = null,
modLoaderType: ModLoaderType? = null,
gameVersionTypeId: Int? = null,
index: Int? = null,
pageSize: Int? = null
): GetModFilesResponse {
return json.decodeFromString(
http.get("/v1/mods/$modId/files") {
parameter("gameVersion", gameVersion)
parameter("modLoaderType", modLoaderType)
parameter("gameVersionTypeId", gameVersionTypeId)
parameter("index", index)
parameter("pageSize", pageSize)
}.body()
)
}
/**
* Get a list of files.
*/
suspend fun getFiles(fileIds: IntArray): Array<File> {
return json.decodeFromString<GetFilesResponse>(
http.post("/v1/mods/files") {
headers.append("Content-Type", "application/json")
setBody(json.encodeToString(GetModFilesRequestBody(fileIds)))
}.body()
).data
}
/**
* Get the changelog of a file in HTML format
*/
suspend fun getModFileChangelog(modId: Int, fileId: Int): String {
return json.decodeFromString<StringResponse>(
http.get("/v1/mods/$modId/files/$fileId/changelog").body()
).data
}
/**
* Get a download url for a specific file
*/
suspend fun getModFileDownloadURL(modId: Int, fileId: Int): String {
return json.decodeFromString<StringResponse>(
http.get("/v1/mods/$modId/files/$fileId/download-url").body()
).data
}
//endregion
}

View File

@@ -0,0 +1,271 @@
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.model.file.File
import top.jie65535.jcf.model.mod.Mod
import top.jie65535.jcf.util.PagedList
import top.jie65535.jcf.util.HttpUtil
import java.text.DecimalFormat
import java.util.regex.Pattern
import kotlin.math.min
class MessageHandler(
private val service: MinecraftService,
private val eventChannel: EventChannel<Event>,
private val logger: MiraiLogger
) {
fun startListen() {
eventChannel.subscribeMessages {
for ((modClass, command) in PluginConfig.searchCommands) {
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}) $modClass \"$filter\"")
val pagedList = service.search(modClass, filter)
with(pagedList.current()) {
if (isEmpty()) {
subject.sendMessage("未搜索到相关结果")
} else if (size == 1) {
handleShowMod(get(0))
} else {
handleModsSearchResult(pagedList)
}
}
} catch (e: Throwable) {
subject.sendMessage(message.quote() + "发生内部错误,请稍后重试")
logger.error("消息\"$message\"引发异常", e)
}
}// if
}
}// for
}
}
/**
* 处理分页列表选择功能返回用户选中项返回null表示未选中任何项
* @param pagedList 分页的列表
* @param format 格式化方法
* @return 用户选中项null表示未选择任何项
*/
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
}
/**
* 处理mod搜索结果
*/
private suspend fun MessageEvent.handleModsSearchResult(pagedList: PagedList<Mod>) {
val selectedMod = handlePagedList(pagedList) { mod ->
val text = PlainText(
"""
[${mod.name}] by ${mod.authors.firstOrNull()?.name}
${formatCount(mod.downloadCount)} Downloads Updated ${mod.dateModified.toLocalDate()}
${mod.summary}
${mod.links.websiteUrl}
""".trimIndent())
mod.logo?.thumbnailUrl?.let { loadImage(it) + text } ?: text
}
selectedMod?.let { handleShowMod(it) }
}
/**
* 处理展示单个mod
*/
private suspend fun MessageEvent.handleShowMod(mod: Mod) {
subject.sendMessage(
buildForwardMessage {
// logo
mod.logo?.thumbnailUrl?.let {
if (it.isNotBlank()) bot says loadImage(it)
}
// basic info
bot says PlainText(with(mod) {
"""
$name
作者:${authors.joinToString { it.name }}
概括:$summary
项目ID$id
创建时间:${dateCreated.toLocalDate()}
最近更新:${dateModified.toLocalDate()}
总下载:$downloadCount
主页:${mod.links.websiteUrl}
""".trimIndent()
})
var msg = "$WAIT_REPLY_TIMEOUT_S 秒内回复 $SUBSCRIBE_KEYWORD 订阅模组更新"
if (mod.latestFiles.isNotEmpty()) {
msg += "\n回复编号查看文件详细信息\n" +
"回复 $VIEW_FILES_KEYWORD 查看全部历史文件"
}
bot says msg
for ((i, file) in mod.latestFiles.withIndex()) {
bot.id named i.toString() says PlainText(
"""
${file.displayName} [${file.releaseType}] [${file.fileDate.toLocalDate()}]
${file.downloadUrl}
""".trimIndent()
)
}
}
)
try {
// 获取用户回复
val next = withTimeout(WAIT_REPLY_TIMEOUT_S * 1000L) {
eventChannel.nextEvent<MessageEvent>(EventPriority.MONITOR) { it.sender == sender }
}
val nextMessage = next.message.content
val subsHandler = PluginMain.subscribeHandler
if (nextMessage.equals(SUBSCRIBE_KEYWORD, true)) {
if (next is GroupMessageEvent) {
subsHandler.sub(mod.id, next.sender.id, next.group.id)
} else {
subsHandler.sub(mod.id, next.sender.id)
}
subject.sendMessage(QuoteReply(next.source) + "已添加订阅")
} else if (mod.latestFiles.isNotEmpty()) {
if (nextMessage.equals(VIEW_FILES_KEYWORD, true)) {
// 查看所有文件
handleModFileList(service.getModFiles(mod.id))
} else {
// 查看文件详情
handleModFile(mod.latestFiles[nextMessage.toInt()])
}
}
} catch (e: Throwable) {
// 忽略因回复引发的异常,无论是超时、越界还是格式不正确,不提示错误
}
}
/**
* 处理模组文件列表
*/
private suspend fun MessageEvent.handleModFileList(pagedList: PagedList<File>) {
val selectedFile = handlePagedList(pagedList) { file ->
PlainText(
"""
${file.displayName} [${file.releaseType}] [${file.fileDate.toLocalDate()}]
${file.downloadUrl}
""".trimIndent()
)
}
selectedFile?.let { handleModFile(it) }
}
/**
* 处理模组文件具体信息展示
*/
private suspend fun MessageEvent.handleModFile(file: File) {
try {
// 暂时仅展示文件更改日志,可以添加文件依赖相关信息的显示
subject.sendMessage(handleModFileChangelog(service.getModFileChangelog(file.modId, file.id)))
} catch (e: Throwable) {
logger.warning("获取文件[${file.fileName}]更改日志时异常", e)
subject.sendMessage("获取文件更改日志时异常,请稍后重试")
}
}
/**
* 处理模组文件更改日志
* @param changelog 更改日志HTML
*/
private fun MessageEvent.handleModFileChangelog(changelog: String): Message {
val logs = HTMLPattern.matcher(changelog).replaceAll("")
return sendLargeMessage(logs)
}
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_FILES_KEYWORD = "ALL"
private const val SUBSCRIBE_KEYWORD = "订阅"
private const val ONE_GRP_SIZE = 5000
private const val ONE_MSG_SIZE = 500
private val singleDecimalFormat = DecimalFormat("0.#")
private fun formatCount(count: Long): String = when {
count < 1000000 -> singleDecimalFormat.format(count / 1000) + "K"
count < 1000000000 -> singleDecimalFormat.format(count / 1000000) + "M"
else -> count.toString()
}
private suspend fun MessageEvent.loadImage(url: String): Image {
val imgFileName = url.substringAfterLast("/")
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
}
val HTMLPattern: Pattern = Pattern.compile("<[^>]+>", Pattern.CASE_INSENSITIVE)
fun MessageEvent.sendLargeMessage(message: String): Message {
return buildForwardMessage {
for (g in message.indices step ONE_GRP_SIZE) {
for (i in g until g + min(ONE_GRP_SIZE, message.length - g) step ONE_MSG_SIZE) {
bot says PlainText(message.subSequence(i, i + (min(ONE_MSG_SIZE, message.length - i))))
}
}
}
}// fun
}
}

View File

@@ -0,0 +1,122 @@
package top.jie65535.jcf
import top.jie65535.jcf.model.file.File
import top.jie65535.jcf.model.mod.Mod
import top.jie65535.jcf.model.request.ModsSearchSortField
import top.jie65535.jcf.model.request.SortOrder
import top.jie65535.jcf.util.PagedList
class MinecraftService(apiKey: String) {
companion object {
private const val GAME_ID_MINECRAFT = 432
private const val DEFAULT_PAGE_SIZE = 10
private val DEFAULT_SORT_FIELD = ModsSearchSortField.Popularity
}
/**
* mod分类
*/
enum class ModClass(val className: String, val classId: Int) {
/**
* 存档
*/
WORLDS("存档",17),
/**
* 水桶服插件
*/
BUKKIT_PLUGINS("水桶服插件", 5),
/**
* 自定义
*/
CUSTOMIZATION("定制", 4546),
/**
* 整合包
*/
MODPACKS("整合包", 4471),
/**
* 资源包
*/
RESOURCE_PACKS("资源包", 12),
/**
* 附加
*/
ADDONS("附加", 4559),
/**
* 模组
*/
MODS("模组", 6);
}
/**
* api客户端实例
*/
private val api = CurseforgeApi(apiKey)
/**
* 根据分类与过滤器进行搜索,返回分页的列表
* @param modClass mod分类
* @param filter 过滤器
* @return 模组分页列表
*/
fun search(modClass: ModClass, filter: String): PagedList<Mod> =
PagedList(DEFAULT_PAGE_SIZE) { index ->
val response = api.searchMods(
GAME_ID_MINECRAFT,
modClass.classId,
searchFilter = filter,
sortField = DEFAULT_SORT_FIELD,
sortOrder = SortOrder.DESC,
index = index,
pageSize = DEFAULT_PAGE_SIZE
)
response.data
}
/**
* 根据模组ID获取指定模组
*/
suspend fun getMod(modId: Int) = api.getMod(modId)
/**
* 根据模组Id列表获取指定模组列表
*/
suspend fun getMods(modIds: IntArray) = api.getMods(modIds)
/**
* 获取指定模组文件
*/
suspend fun getModFile(modId: Int, fileId: Int) = api.getModFile(modId, fileId)
/**
* 获取模组文件列表,返回分页的列表
* @return 分页的列表
*/
suspend fun getModFiles(modId: Int): PagedList<File> =
PagedList(DEFAULT_PAGE_SIZE) { index ->
val response = api.getModFiles(
modId,
index = index,
pageSize = DEFAULT_PAGE_SIZE
)
response.data
}
/**
* 获取文件更改日志结果为HTML文本
* @return Changelog HTML
*/
suspend fun getModFileChangelog(modId: Int, fileId: Int) =
api.getModFileChangelog(modId, fileId)
/**
* 获取文件下载地址
*/
suspend fun getModFileDownloadURL(modId: Int, fileId: Int) =
api.getModFileDownloadURL(modId, fileId)
}

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

@@ -0,0 +1,104 @@
package top.jie65535.jcf
import net.mamoe.mirai.console.command.CommandSender
import net.mamoe.mirai.console.command.CompositeCommand
object PluginCommands : CompositeCommand(PluginMain, "jcf") {
@SubCommand
@Description("设置Curseforge API Key")
suspend fun CommandSender.setApiKey(apiKey: String) {
PluginConfig.apiKey = apiKey
sendMessage("OK! 重启插件生效")
}
@SubCommand
@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())
}
@SubCommand
@Description("设置订阅信息推送botqq id")
suspend fun CommandSender.setSubsSender(sender: Long) {
PluginConfig.subscribeSender = sender
sendMessage("OK! ")
}
@SubCommand
@Description("设置检查间隔(单位:秒)")
suspend fun CommandSender.setCheckInterval(second: Long) {
PluginConfig.checkInterval = second
sendMessage("OK! 将在下次检查结束后应用")
}
@SubCommand
@Description("查看 CurseForge 订阅处理的状态")
suspend fun CommandSender.subStat() {
if (!PluginMain.isCurseForgeEnabled) {
sendMessage("CurseForge 未配置 API Key订阅功能不可用")
return
}
val subs = PluginMain.subscribeHandler
if (subs.isIdle) {
sendMessage("CurseForge 订阅器闲置中")
} else {
sendMessage("CurseForge 订阅处理正常运行中")
}
}
@SubCommand
@Description("使 CurseForge 订阅器闲置")
suspend fun CommandSender.idleSubs() {
if (!PluginMain.isCurseForgeEnabled) {
sendMessage("CurseForge 未配置 API Key订阅功能不可用")
return
}
PluginMain.subscribeHandler.idle()
sendMessage("OK已闲置")
}
@SubCommand
@Description("使 CurseForge 订阅器恢复运行")
suspend fun CommandSender.runSubs() {
if (!PluginMain.isCurseForgeEnabled) {
sendMessage("CurseForge 未配置 API Key订阅功能不可用")
return
}
PluginMain.subscribeHandler.start()
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

@@ -0,0 +1,47 @@
package top.jie65535.jcf
import net.mamoe.mirai.console.data.AutoSavePluginConfig
import net.mamoe.mirai.console.data.ValueDescription
import net.mamoe.mirai.console.data.value
object PluginConfig : AutoSavePluginConfig("JCurseforgeConfig") {
@ValueDescription("Curseforge API KEY")
var apiKey: String by value()
@ValueDescription("搜索命令 (MODS,MODPACKS,RESOURCE_PACKS,WORLDS,BUKKIT_PLUGINS,ADDONS,CUSTOMIZATION)")
val searchCommands: MutableMap<MinecraftService.ModClass, String> by value(
mutableMapOf(
MinecraftService.ModClass.MODS to "cfmod ",
MinecraftService.ModClass.MODPACKS to "cfpack ",
MinecraftService.ModClass.RESOURCE_PACKS to "cfres ",
MinecraftService.ModClass.WORLDS to "cfworld ",
MinecraftService.ModClass.BUKKIT_PLUGINS to "cfbukkit ",
MinecraftService.ModClass.ADDONS to "cfaddon ",
MinecraftService.ModClass.CUSTOMIZATION to "cfcustom ",
)
)
@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
*/
@ValueDescription("订阅信息推送botqq id")
var subscribeSender: Long by value(-1L)
/**
* 检查间隔
*/
@ValueDescription("检查间隔(单位:秒)")
var checkInterval: Long by value(60 * 60 * 4L)
}

View File

@@ -0,0 +1,64 @@
package top.jie65535.jcf
import net.mamoe.mirai.console.data.AutoSavePluginData
import net.mamoe.mirai.console.data.value
import net.mamoe.mirai.console.data.ValueDescription
object PluginData : AutoSavePluginData("JCurseforgeData") {
/**
* 模组最新文件的id集合 (CurseForge)
* ```json
* {
* mod_id: file_id,
* ...
* }
* ```
*/
@ValueDescription("模组最新文件的id集合")
var modsLastFile: MutableMap<Int, Int> by value()
/**
* 订阅记录 (CurseForge)
* ```json
* {
* mod_id: {
* group_id: [ qq_id, ... ],
* ...
* },
* ...
* }
* ```
* 个人订阅时group为0
*/
@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

@@ -0,0 +1,74 @@
package top.jie65535.jcf
import kotlinx.coroutines.launch
import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register
import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
import net.mamoe.mirai.event.GlobalEventChannel
import net.mamoe.mirai.utils.info
object PluginMain: KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.jcf",
name = "J Curseforge Util",
version = "1.2.0",
) {
author("jie65535")
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 KeyCurseForge 相关功能不可用!\n" +
"请使用 /jcf setApiKey <apiKey> 命令来设置key\n" +
"Api key 可以在开发者控制台生成https://console.curseforge.com/")
} 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
}
logger.info { "Plugin Enabled" }
}
}

View File

@@ -0,0 +1,316 @@
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.mod.Mod
/**
* 处理订阅
*
* @param service api服务
* @param logger 日志
*/
class SubscribeHandler(
private val service: MinecraftService,
private val logger: MiraiLogger,
) {
// region -- 参数
/**
* 清理订阅
*
* @param mod 模组id为null将清空所有订阅
* @param group 群号为null将移除指定mod下所有订阅
*/
fun clean(mod: Int? = null, group: Long? = null) {
var updInner = false
val modSet = HashMap(PluginData.modsLastFile)
val subSet = HashMap(PluginData.subscriptionSet)
if (mod == null) {
subSet.clear()
modSet.clear()
logger.info("清理所有订阅")
} else if (group == null) {
modSet -= mod
subSet -= mod
logger.info("清理mod[${MOD_INFO_CACHE[mod]?.name}]的订阅")
} else {
subSet[mod]?.let {
logger.info("清理群/分组[$group]的订阅")
updInner = true
it -= group
}
}
if (modSet.size != PluginData.modsLastFile.size) {
PluginData.modsLastFile = modSet
}
if (subSet.size != PluginData.subscriptionSet.size || updInner) {
PluginData.subscriptionSet = subSet
}
}
/**
* 取消订阅
*
* @param mod 模组id
* @param qq 个人q号或群成员q号
* @param group 群号为null时默认表示个人订阅
*/
fun unsub(mod: Int, qq: Long, group: Long? = null) {
if (mod < 0 || qq < 0) return
val gid = group ?: GROUP_ID_SINGLE
val subSet = HashMap(PluginData.subscriptionSet)
val groups = subSet[mod] ?: return
val members = groups[gid] ?: return
members -= qq
PluginData.subscriptionSet = subSet
logger.info("取消订阅--{$mod:{$gid:[$qq]}}")
}
/**
* 记录订阅
*
* @param mod 模组id
* @param qq q号
* @param group 群号为null时默认表示个人订阅
*/
fun sub(mod: Int, qq: Long, group: Long? = null) {
if (mod < 0 || qq < 0) return
val gid = group ?: GROUP_ID_SINGLE
val modSet = HashMap(PluginData.modsLastFile)
val subSet = HashMap(PluginData.subscriptionSet)
if (mod !in modSet) modSet[mod] = -1
val groupSet = subSet[mod] ?: mutableMapOf()
val qqSet = groupSet[gid] ?: mutableListOf()
var changed = gid !in groupSet
subSet[mod] = groupSet
groupSet[gid] = qqSet
if (qq !in qqSet) {
qqSet += qq
changed = true
logger.info("添加订阅--{$mod:{$gid:[$qq]}}")
}
if (mod !in PluginData.modsLastFile) {
PluginData.modsLastFile = modSet
}
if (changed) {
PluginData.subscriptionSet = subSet
}
}
// endregion
// region -- 流程
/**
* 检查更新
*
* @param init 是否初始化
* @return 检查到更新的{ mod : newFileId }
*/
private suspend fun checkUpdate(init: Boolean = false) = flow {
val oldSet = PluginData.modsLastFile
if (oldSet.isNotEmpty()) {
val fetchMods = service.getMods(oldSet.keys.toIntArray())
.asSequence()
.map { it.id to it }
.toMap()
for ((mod, old) in oldSet) {
try {
val new = fetchMods[mod]
if (new == null) {
emit(mod to -1)
continue
}
MOD_INFO_CACHE[mod] = new
val last = new.latestFilesIndexes[0].fileId
if (old != last || init) {
emit(mod to last)
logger.info("模组更新【${new.name}")
}
} catch (e: Exception) {
logger.warning("err msg: ${e.message}")
emit(mod to -1)
continue
}
}// for
}
}
/**
* 获取更新日志
*
* @param mod 模组id
* @param file 文件id
* @return 更新日志
*/
private suspend fun getChangeLogs(mod: Int, file: Int): String = try {
val changelog = service.getModFileChangelog(mod, file)
MessageHandler.HTMLPattern.matcher(changelog)
.replaceAll("")
.replace(Regex("\n+"), "\n")
} catch (e: Exception) {
logger.warning("err msg: ${e.message}")
""
}
/**
* 执行发送
*
* @param sender 发送消息的bot
* @param modLogs { mod : changeLog }
*/
private suspend fun send(sender: Bot, modLogs: Pair<Int, String>) {
val (mod, logs) = modLogs
if (logs.isBlank()) return
val subGroups = PluginData.subscriptionSet[mod] ?: return
val modName = MOD_INFO_CACHE[mod]?.name ?: return
val title = "你订阅的mod【$modName】更新啦!"
val context = "更新日志:\n$logs"
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)
}
}// if else
}// foreach
}
/**
* 准备发送更新日志
*
* @param senderQQ 指定发送消息的机器人id
* @param updMod { mod : file }
*/
private suspend fun feedback(senderQQ: Long, updMod: Pair<Int, Int>) {
val (mod, file) = updMod
if (mod < 0) return
Bot.instances.firstOrNull {
it.isOnline && it.id == senderQQ
}?.let { sender ->
val log = getChangeLogs(mod, file)
send(sender, mod to log)
}// let
}
/**
* 循环执行
*/
private fun CoroutineScope.loop() = launch {
val senderQQ = PluginConfig.subscribeSender
if (senderQQ < 0) {
logger.warning("必须配置订阅信息推送botqq id才可以进行订阅推送")
logger.warning("插件会持续收集订阅与检查mod更新但无法进行消息推送。")
}
logger.info("subscription listening")
while (true) {
delay(1000 * PluginConfig.checkInterval)
if (isIdle) continue
val subSet = HashMap(PluginData.subscriptionSet)
val modFiles = HashMap(PluginData.modsLastFile)
checkUpdate()
.buffer()
.collect {
val (mod, file) = it
if (file < 0) {
modFiles -= mod
subSet -= mod
} else {
modFiles[mod] = file
feedback(senderQQ, it)
}
}
PluginData.subscriptionSet = subSet
PluginData.modsLastFile = modFiles
}// while
}
// endregion
// region -- 状态
/**
* 是否闲置
*/
var isIdle = true
private set
/**
* 取消闲置
*/
fun start() {
isIdle = false
}
/**
* 进入闲置
*/
fun idle() {
isIdle = true
}
/**
* 初始化,并开始订阅循环
*
* @param scope 指定协程上下文
*/
suspend fun load(scope: CoroutineScope) {
logger.info("loading plugin data...")
val subs = HashMap(PluginData.subscriptionSet)
val files = HashMap(PluginData.modsLastFile)
checkUpdate(true)
.buffer()
.collect { (mod, file) ->
if (file < 0) {
files -= mod
subs -= mod
} else {
files[mod] = file
}
}
PluginData.subscriptionSet = subs
PluginData.modsLastFile = files
scope.loop()
}
// endregion
companion object {
/**
* 标识个人订阅
*/
const val GROUP_ID_SINGLE: Long = 0
/**
* 缓存模组信息
*/
private val MOD_INFO_CACHE: MutableMap<Int, Mod> = mutableMapOf()
}
}

View File

@@ -0,0 +1,64 @@
package top.jie65535.jcf.model
import kotlinx.serialization.Serializable
import top.jie65535.jcf.util.OffsetDateTimeSerializer
import java.time.OffsetDateTime
@Serializable
class Category(
/**
* The category id
*/
val id: Int,
/**
* The game id related to the category
*/
val gameId: Int,
/**
* Category name
*/
val name: String,
/**
* The category slug as it appear in the URL
*/
val slug: String?,
/**
* The category URL
*/
val url: String?,
/**
* URL for the category icon
*/
val iconUrl: String?,
/**
* Last modified date of the category
*/
@Serializable(OffsetDateTimeSerializer::class)
val dateModified: OffsetDateTime,
/**
* A top level category for other categories
*/
val isClass: Boolean? = null,
/**
* The class id of the category, meaning - the class of which this category is under
*/
val classId: Int? = null,
/**
* The parent category for this category
*/
val parentCategoryId: Int? = null,
/**
* The display index for this category
*/
val displayIndex: Int? = null
)

View File

@@ -0,0 +1,24 @@
package top.jie65535.jcf.model
@kotlinx.serialization.Serializable
class Pagination(
/**
* A zero based index of the first item that is included in the response
*/
val index: Int,
/**
* The requested number of items to be included in the response
*/
val pageSize: Int,
/**
* The actual number of items that were included in the response
*/
val resultCount: Int,
/**
* The total number of items available by the request
*/
val totalCount: Long,
)

View File

@@ -0,0 +1,34 @@
package top.jie65535.jcf.model
import kotlinx.serialization.Serializable
import top.jie65535.jcf.util.OffsetDateTimeSerializer
import java.time.OffsetDateTime
@Serializable
class SortableGameVersion(
/**
* Original version name (e.g. 1.5b)
*/
val gameVersionName: String,
/**
* Used for sorting (e.g. 0000000001.0000000005)
*/
val gameVersionPadded: String,
/**
* game version clean name (e.g. 1.5)
*/
val gameVersion: String,
/**
* Game version release date
*/
@Serializable(OffsetDateTimeSerializer::class)
val gameVersionReleaseDate: OffsetDateTime,
/**
* Game version type id
*/
val gameVersionTypeId: Int? = null
)

View File

@@ -0,0 +1,103 @@
package top.jie65535.jcf.model.file
import kotlinx.serialization.Serializable
import top.jie65535.jcf.model.SortableGameVersion
import top.jie65535.jcf.util.OffsetDateTimeSerializer
import java.time.OffsetDateTime
@Serializable
class File(
/**
* The file id
*/
val id: Int,
/**
* The game id related to the mod that this file belongs to
*/
val gameId: Int,
/**
* The mod id
*/
val modId: Int,
/**
* Whether the file is available to download
*/
val isAvailable: Boolean,
/**
* Display name of the file
*/
val displayName: String,
/**
* Exact file name
*/
val fileName: String,
/**
* The file release type
*/
val releaseType: FileReleaseType,
/**
* Status of the file
*/
val fileStatus: FileStatus,
/**
* The file hash (i.e. md5 or sha1)
*/
val hashes: Array<FileHash>,
/**
* The file timestamp
*/
@Serializable(OffsetDateTimeSerializer::class)
val fileDate: OffsetDateTime,
/**
* The file length in bytes
*/
val fileLength: Long,
/**
* The number of downloads for the file
*/
val downloadCount: Long,
/**
* The file download URL
*/
val downloadUrl: String?,
/**
* List of game versions this file is relevant for
*/
val gameVersions: Array<String>,
/**
* Metadata used for sorting by game versions
*/
val sortableGameVersions: Array<SortableGameVersion>,
/**
* List of dependencies files
*/
val dependencies: Array<FileDependency>,
/**
* none
*/
val exposeAsAlternative: Boolean? = null,
/**
* none
*/
val parentProjectFileId: Int? = null,
/**
* none
*/
val alternateFileId: Int? = null,
/**
* none
*/
val isServerPack: Boolean? = null,
/**
* none
*/
val serverPackFileId: Int? = null,
/**
* none
*/
val fileFingerprint: Long,
/**
* none
*/
val modules: Array<FileModule>,
)

View File

@@ -0,0 +1,7 @@
package top.jie65535.jcf.model.file
@kotlinx.serialization.Serializable
class FileDependency(
val modId: Int,
val relationType: FileRelationType,
)

View File

@@ -0,0 +1,7 @@
package top.jie65535.jcf.model.file
@kotlinx.serialization.Serializable
class FileHash(
val value: String,
val algo: HashAlgo,
)

View File

@@ -0,0 +1,13 @@
package top.jie65535.jcf.model.file
import top.jie65535.jcf.model.mod.ModLoaderType
@kotlinx.serialization.Serializable
class FileIndex(
val gameVersion: String,
val fileId: Int,
val filename: String,
val releaseType: FileReleaseType,
val gameVersionTypeId: Int? = null,
val modLoader: ModLoaderType? = null,
)

View File

@@ -0,0 +1,7 @@
package top.jie65535.jcf.model.file
@kotlinx.serialization.Serializable
class FileModule(
val name: String,
val fingerprint: Long,
)

View File

@@ -0,0 +1,16 @@
package top.jie65535.jcf.model.file
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = FileRelationType.IndexSerializer::class)
enum class FileRelationType {
EmbeddedLibrary,
OptionalDependency,
RequiredDependency,
Tool,
Incompatible,
Include;
internal object IndexSerializer : KSerializer<FileRelationType> by EnumIndexSerializer(values())
}

View File

@@ -0,0 +1,13 @@
package top.jie65535.jcf.model.file
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = FileReleaseType.IndexSerializer::class)
enum class FileReleaseType {
Release,
Beta,
Alpha;
internal object IndexSerializer : KSerializer<FileReleaseType> by EnumIndexSerializer(values())
}

View File

@@ -0,0 +1,25 @@
package top.jie65535.jcf.model.file
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = FileStatus.IndexSerializer::class)
enum class FileStatus{
Processing,
ChangesRequired,
UnderReview,
Approved,
Rejected,
MalwareDetected,
Deleted,
Archived,
Testing,
Released,
ReadyForReview,
Deprecated,
Baking,
AwaitingPublishing,
FailedPublishing;
internal object IndexSerializer : KSerializer<FileStatus> by EnumIndexSerializer(values())
}

View File

@@ -0,0 +1,12 @@
package top.jie65535.jcf.model.file
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = HashAlgo.IndexSerializer::class)
enum class HashAlgo(val value: Int) {
Sha1(1),
Md5(2);
internal object IndexSerializer : KSerializer<HashAlgo> by EnumIndexSerializer(values())
}

View File

@@ -0,0 +1,12 @@
package top.jie65535.jcf.model.game
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = CoreApiStatus.IndexSerializer::class)
enum class CoreApiStatus {
Private,
Public;
internal object IndexSerializer : KSerializer<CoreApiStatus> by EnumIndexSerializer(values())
}

View File

@@ -0,0 +1,16 @@
package top.jie65535.jcf.model.game
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = CoreStatus.IndexSerializer::class)
enum class CoreStatus {
Draft,
Test,
PendingReview,
Rejected,
Approved,
Live;
internal object IndexSerializer : KSerializer<CoreStatus> by EnumIndexSerializer(values())
}

View File

@@ -0,0 +1,17 @@
package top.jie65535.jcf.model.game
import kotlinx.serialization.Serializable
import top.jie65535.jcf.util.OffsetDateTimeSerializer
import java.time.OffsetDateTime
@Serializable
class Game(
val id: Int,
val name: String,
val slug: String?,
@Serializable(OffsetDateTimeSerializer::class)
val dateModified: OffsetDateTime,
val assets: GameAssets,
val status: CoreStatus,
val apiStatus: CoreApiStatus,
)

View File

@@ -0,0 +1,8 @@
package top.jie65535.jcf.model.game
@kotlinx.serialization.Serializable
class GameAssets(
val iconUrl: String?,
val tileUrl: String?,
val coverUrl: String?,
)

View File

@@ -0,0 +1,9 @@
package top.jie65535.jcf.model.game
@kotlinx.serialization.Serializable
class GameVersionType(
val id: Int? = null,
val gameId: Int? = null,
val name: String? = null,
val slug: String? = null,
)

View File

@@ -0,0 +1,140 @@
package top.jie65535.jcf.model.mod
import kotlinx.serialization.Serializable
import top.jie65535.jcf.model.Category
import top.jie65535.jcf.model.file.File
import top.jie65535.jcf.model.file.FileIndex
import top.jie65535.jcf.util.OffsetDateTimeSerializer
import java.time.OffsetDateTime
@Serializable
class Mod(
/**
* The mod id
*/
val id: Int,
/**
* The game id this mod is for
*/
val gameId: Int,
/**
* The name of the mod
*/
val name: String,
/**
* The mod slug that would appear in the URL
*/
val slug: String?,
/**
* Relevant links for the mod such as Issue tracker and Wiki
*/
val links: ModLinks,
/**
* Mod summary
*/
val summary: String,
/**
* Current mod status
*/
val status: ModStatus,
/**
* Number of downloads for the mod
*/
val downloadCount: Long,
/**
* Whether the mod is included in the featured mods list
*/
val isFeatured: Boolean,
/**
* The main category of the mod as it was chosen by the mod author
*/
val primaryCategoryId: Int,
/**
* List of categories that this mod is related to
*/
val categories: Array<Category>,
/**
* The class id this mod belongs to
*/
val classId: Int? = null,
/**
* List of the mod's authors
*/
val authors: Array<ModAuthor>,
/**
* The mod's logo asset
*/
val logo: ModAsset?,
/**
* List of screenshots assets
*/
val screenshots: Array<ModAsset>,
/**
* The id of the main file of the mod
*/
val mainFileId: Int,
/**
* List of latest files of the mod
*/
val latestFiles: Array<File>,
/**
* List of file related details for the latest files of the mod
*/
val latestFilesIndexes: Array<FileIndex>,
/**
* The creation date of the mod
*/
@Serializable(OffsetDateTimeSerializer::class)
val dateCreated: OffsetDateTime,
/**
* The last time the mod was modified
*/
@Serializable(OffsetDateTimeSerializer::class)
val dateModified: OffsetDateTime,
/**
* The release date of the mod
*/
@Serializable(OffsetDateTimeSerializer::class)
val dateReleased: OffsetDateTime,
/**
* Is mod allowed to be distributed
*/
val allowModDistribution: Boolean? = null,
/**
* The mod popularity rank for the game
*/
val gamePopularityRank: Int,
/**
* Is the mod available for search. This can be false when a mod is experimental,
* in a deleted state or has only alpha files
*/
val isAvailable: Boolean,
/**
* The mod's thumbs up count
*/
val thumbsUpCount: Int,
)

View File

@@ -0,0 +1,11 @@
package top.jie65535.jcf.model.mod
@kotlinx.serialization.Serializable
class ModAsset(
val id: Int,
val modId: Int,
val title: String,
val description: String,
val thumbnailUrl: String?,
val url: String?,
)

View File

@@ -0,0 +1,8 @@
package top.jie65535.jcf.model.mod
@kotlinx.serialization.Serializable
class ModAuthor(
val id: Int,
val name: String,
val url: String?,
)

View File

@@ -0,0 +1,9 @@
package top.jie65535.jcf.model.mod
@kotlinx.serialization.Serializable
class ModLinks(
val websiteUrl: String?,
val wikiUrl: String?,
val issuesUrl: String?,
val sourceUrl: String?
)

View File

@@ -0,0 +1,16 @@
package top.jie65535.jcf.model.mod
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = ModLoaderType.IndexSerializer::class)
enum class ModLoaderType {
Any,
Forge,
Cauldron,
LiteLoader,
Fabric,
Quilt;
internal object IndexSerializer : KSerializer<ModLoaderType> by EnumIndexSerializer(values())
}

View File

@@ -0,0 +1,20 @@
package top.jie65535.jcf.model.mod
import kotlinx.serialization.KSerializer
import top.jie65535.jcf.util.EnumIndexSerializer
@kotlinx.serialization.Serializable(with = ModStatus.IndexSerializer::class)
enum class ModStatus {
New,
ChangesRequired,
UnderSoftReview,
Approved,
Rejected,
ChangesMade,
Inactive,
Abandoned,
Deleted,
UnderReview;
internal object IndexSerializer : KSerializer<ModStatus> by EnumIndexSerializer(values())
}

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

Some files were not shown because too many files have changed in this diff Show More