diff --git a/README.md b/README.md index 8c182b0..31147df 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,14 @@ jinaApiKey: '' searXngUrl: '' # 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。 glotToken: '' +# 和风天气专属 API Host,可在和风天气控制台的设置页面查看 +qWeatherApiHost: '' +# 和风天气项目 ID +qWeatherProjectId: '' +# 和风天气 JWT 凭据 ID +qWeatherCredentialId: '' +# Ed25519 私钥路径,相对于插件配置目录,也可以填写绝对路径 +qWeatherPrivateKeyPath: 'qweather-ed25519-private.pem' # 群管理是否自动拥有对话权限,默认是 groupOpHasChatPermission: true # 好友是否自动拥有对话权限,默认是 @@ -160,6 +168,25 @@ searchHistoryMaxDays: 30 searchHistoryMaxRecords: 5000 ``` +### 和风天气 + +天气工具使用[和风天气开发服务](https://dev.qweather.com/docs/start/)和 JWT 凭据,简单配置流程如下: + +1. 注册和风天气开发者帐号,在控制台创建项目,并在“设置”中查看专属 API Host。 +2. 使用 OpenSSL 在本地生成 Ed25519 密钥: + + ```bash + openssl genpkey -algorithm ED25519 -out qweather-ed25519-private.pem + openssl pkey -pubout -in qweather-ed25519-private.pem -out qweather-ed25519-public.pem + ``` + +3. 在项目中添加凭据,认证方式选择 `JSON Web Token`,上传公钥并勾选需要使用的天气、GeoAPI 和预警接口。 +4. 将私钥放到插件配置目录,例如 `config/top.jie65535.mirai.JChatGPT/qweather-ed25519-private.pem`,然后在 `Config.yml` 填写 API Host、项目 ID、凭据 ID 和私钥路径。 + +私钥只保存在部署机器上,请勿上传或提交到 Git。配置不完整时天气工具不会启用,修改后执行 `/jgpt reload`。 + +天气工具支持实时天气、每日预报、逐小时预报、分钟级降水和官方天气预警。 + ## 系统提示词 JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件位于插件配置目录下的 `SystemPrompt.md` 文件中。 @@ -566,4 +593,4 @@ JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消 - 如果默认的 API 调用失败,可以更换为其他兼容的 API 地址 - 可根据需要配置代理设置 - 某些工具需要额外的 API 密钥才能启用 -- 插件支持自定义系统提示词,可以通过修改 `SystemPrompt.md` 文件来实现 \ No newline at end of file +- 插件支持自定义系统提示词,可以通过修改 `SystemPrompt.md` 文件来实现 diff --git a/build.gradle.kts b/build.gradle.kts index 1589e89..381e1cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "top.jie65535.mirai" -version = "1.12.0" +version = "1.13.0" mirai { jvmTarget = JavaVersion.VERSION_11 @@ -31,10 +31,12 @@ val jLatexMathVersion = "1.0.7" val commonTextVersion = "1.13.0" val hibernateVersion = "2.9.0" val overflowVersion = "1.0.7" +val eddsaVersion = "0.3.0" dependencies { implementation("com.aallam.openai:openai-client:$openaiClientVersion") implementation("io.ktor:ktor-client-okhttp:$ktorVersion") + implementation("net.i2p.crypto:eddsa:$eddsaVersion") implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion") implementation("org.apache.commons:commons-text:$commonTextVersion") @@ -42,4 +44,4 @@ dependencies { compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion") testConsoleRuntime("top.mrxiaom.mirai:overflow-core:$overflowVersion") -} \ No newline at end of file +} diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index d9011b0..59b1bf8 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -53,7 +53,7 @@ object JChatGPT : KotlinPlugin( JvmPluginDescription( id = "top.jie65535.mirai.JChatGPT", name = "J ChatGPT", - version = "1.12.0", + version = "1.13.0", ) { author("jie65535") // dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true) @@ -1192,4 +1192,4 @@ object JChatGPT : KotlinPlugin( logger.info("好感度时间偏移处理完成") } -} \ No newline at end of file +} diff --git a/src/main/kotlin/PluginConfig.kt b/src/main/kotlin/PluginConfig.kt index 7c8e5fc..acc0a0b 100644 --- a/src/main/kotlin/PluginConfig.kt +++ b/src/main/kotlin/PluginConfig.kt @@ -87,6 +87,18 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。") val glotToken: String by value("") + @ValueDescription("和风天气专属 API Host,例如 abc1234xyz.def.qweatherapi.com") + val qWeatherApiHost: String by value("") + + @ValueDescription("和风天气项目 ID,用于 JWT 的 sub") + val qWeatherProjectId: String by value("") + + @ValueDescription("和风天气凭据 ID,用于 JWT 的 kid") + val qWeatherCredentialId: String by value("") + + @ValueDescription("和风天气 Ed25519 私钥文件路径,相对于插件配置目录,也可以填写绝对路径") + val qWeatherPrivateKeyPath: String by value("qweather-ed25519-private.pem") + @ValueDescription("群管理是否自动拥有对话权限,默认是") val groupOpHasChatPermission: Boolean by value(true) @@ -171,4 +183,4 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("聊天记录搜索最大查询条数,防止内存溢出") val searchHistoryMaxRecords: Int by value(5000) -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/WeatherService.kt b/src/main/kotlin/tools/WeatherService.kt index e291ce0..51aaffa 100644 --- a/src/main/kotlin/tools/WeatherService.kt +++ b/src/main/kotlin/tools/WeatherService.kt @@ -4,27 +4,63 @@ import com.aallam.openai.api.chat.Tool import com.aallam.openai.api.core.Parameters import io.ktor.client.request.* import io.ktor.client.statement.* +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.isSuccess +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.serialization.json.* +import net.i2p.crypto.eddsa.EdDSAEngine +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import top.jie65535.mirai.JChatGPT +import top.jie65535.mirai.PluginConfig +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.spec.PKCS8EncodedKeySpec +import java.time.Instant +import java.util.Base64 class WeatherService : BaseAgent( tool = Tool.function( name = "queryWeather", - description = "可用于查询某城市地区天气.", + description = "查询指定地区的和风天气数据,包括实时天气、每日预报、逐小时预报、分钟级降水和正在生效的官方天气预警。" + + "普通天气查询也会同时返回当地正在生效的预警。", parameters = Parameters.buildJsonObject { put("type", "object") putJsonObject("properties") { putJsonObject("city") { put("type", "string") - put("description", "城市地区,如\"深圳市\"") + put("description", "城市、区县或地区名称,如\"深圳市\"、\"深圳南山区\"") } - putJsonObject("time_range") { + putJsonObject("adm") { + put("type", "string") + put("description", "可选的上级行政区名称,用于区分重名地区,如\"北京市\"、\"广东省\"") + } + putJsonObject("query_type") { put("type", "string") putJsonArray("enum") { - add("day") - add("three") - add("many") + add("now") + add("daily") + add("hourly") + add("minutely") + add("warning") } - put("description", "时间范围,仅当天天气可获得最详细信息,三天和更多只能获得简单信息。") + put("description", "查询类型:实时天气、每日预报、逐小时预报、未来2小时分钟级降水或官方天气预警,默认now") + } + putJsonObject("range") { + put("type", "string") + putJsonArray("enum") { + add("3d") + add("7d") + add("10d") + add("15d") + add("30d") + add("24h") + add("72h") + add("168h") + } + put("description", "daily或hourly的预报范围;daily默认3d,hourly默认24h") } } putJsonArray("required") { @@ -33,24 +69,227 @@ class WeatherService : BaseAgent( } ) ) { + companion object { + private const val JWT_LIFETIME_SECONDS = 900L + private const val JWT_REFRESH_AHEAD_SECONDS = 60L + private val DAILY_RANGES = setOf("3d", "7d", "10d", "15d", "30d") + private val HOURLY_RANGES = setOf("24h", "72h", "168h") + private val json = Json { ignoreUnknownKeys = true } + } + + @Volatile + private var cachedJwt: String? = null + + @Volatile + private var cachedJwtExpiresAt: Long = 0L + + @Volatile + private var cachedJwtConfig: String = "" + + private val jwtLock = Any() + + override val isEnabled: Boolean + get() = PluginConfig.qWeatherApiHost.isNotBlank() && + PluginConfig.qWeatherProjectId.isNotBlank() && + PluginConfig.qWeatherCredentialId.isNotBlank() && + PluginConfig.qWeatherPrivateKeyPath.isNotBlank() && + JChatGPT.resolveConfigFile(PluginConfig.qWeatherPrivateKeyPath).isFile + override val loadingMessage: String get() = "观天中..." override suspend fun execute(args: JsonObject?): String { requireNotNull(args) val city = args.getValue("city").jsonPrimitive.content - val timeRange = args["time_range"]?.jsonPrimitive?.contentOrNull - val response = httpClient.get( - buildString { - append(when (timeRange) { - "many" -> "https://api.52vmy.cn/api/query/tian/many" - "three" -> "https://api.52vmy.cn/api/query/tian/three" - else -> "https://api.52vmy.cn/api/query/tian" - }) - append("?city=") - append(city) + val adm = args["adm"]?.jsonPrimitive?.contentOrNull + val queryType = args["query_type"]?.jsonPrimitive?.contentOrNull ?: "now" + val range = args["range"]?.jsonPrimitive?.contentOrNull + + require(queryType in setOf("now", "daily", "hourly", "minutely", "warning")) { + "不支持的天气查询类型:$queryType" + } + + val location = resolveLocation(city, adm) + val locationId = location.getValue("id").jsonPrimitive.content + val latitude = location.getValue("lat").jsonPrimitive.content + val longitude = location.getValue("lon").jsonPrimitive.content + + val warningPath = "/weatheralert/v1/current/$latitude/$longitude" + val (data, activeWarning) = if (queryType == "warning") { + request(warningPath, mapOf("lang" to "zh")) to null + } else coroutineScope { + val weatherDeferred = async { + when (queryType) { + "daily" -> { + val days = range?.takeIf { it in DAILY_RANGES } ?: "3d" + request("/v7/weather/$days", mapOf("location" to locationId, "lang" to "zh")) + } + + "hourly" -> { + val hours = range?.takeIf { it in HOURLY_RANGES } ?: "24h" + request("/v7/weather/$hours", mapOf("location" to locationId, "lang" to "zh")) + } + + "minutely" -> request( + "/v7/minutely/5m", + mapOf("location" to "$longitude,$latitude", "lang" to "zh") + ) + + else -> request("/v7/weather/now", mapOf("location" to locationId, "lang" to "zh")) + } } - ) - return response.bodyAsText() + val warningDeferred = async { + try { + request(warningPath, mapOf("lang" to "zh")) + } catch (e: Throwable) { + JChatGPT.logger.warning("天气预警查询失败,继续返回天气:${e.message}") + null + } + } + weatherDeferred.await() to warningDeferred.await()?.takeIf(::hasActiveWarnings) + } + + return buildJsonObject { + put("queryType", queryType) + putJsonObject("location") { + put("name", location["name"]?.jsonPrimitive?.contentOrNull ?: city) + put("adm2", location["adm2"]?.jsonPrimitive?.contentOrNull ?: "") + put("adm1", location["adm1"]?.jsonPrimitive?.contentOrNull ?: "") + put("country", location["country"]?.jsonPrimitive?.contentOrNull ?: "") + } + put("attribution", "天气服务由和风天气驱动") + put("data", data) + activeWarning?.let { put("warning", it) } + }.toString() } -} \ No newline at end of file + + private fun hasActiveWarnings(response: JsonObject): Boolean { + return response["alerts"]?.jsonArray?.isNotEmpty() == true + } + + private suspend fun resolveLocation(city: String, adm: String?): JsonObject { + val parameters = buildMap { + put("location", city) + put("number", "1") + put("lang", "zh") + if (!adm.isNullOrBlank()) put("adm", adm) + } + val response = request("/geo/v2/city/lookup", parameters) + val locations = response["location"]?.jsonArray + require(!locations.isNullOrEmpty()) { "未找到地区:$city" } + return locations.first().jsonObject + } + + private suspend fun request(path: String, parameters: Map): JsonObject { + var response = requestOnce(path, parameters, forceRefreshJwt = false) + if (response.first == HttpStatusCode.Unauthorized) { + invalidateJwt() + response = requestOnce(path, parameters, forceRefreshJwt = true) + } + + val status = response.first + val body = response.second + require(status.isSuccess()) { + "和风天气请求失败:HTTP ${status.value} ${status.description},响应:${body.take(500)}" + } + + val result = try { + json.parseToJsonElement(body).jsonObject + } catch (e: Throwable) { + throw IllegalStateException("和风天气返回了无法解析的数据:${body.take(500)}", e) + } + + val code = result["code"]?.jsonPrimitive?.contentOrNull + require(code == null || code == "200") { + "和风天气返回错误码 $code:${body.take(500)}" + } + return result + } + + private suspend fun requestOnce( + path: String, + parameters: Map, + forceRefreshJwt: Boolean + ): Pair { + val response = httpClient.get(apiBaseUrl() + path) { + header(HttpHeaders.Authorization, "Bearer ${jwt(forceRefreshJwt)}") + parameters.forEach { (name, value) -> parameter(name, value) } + } + return response.status to response.bodyAsText() + } + + private fun apiBaseUrl(): String { + val host = PluginConfig.qWeatherApiHost.trim().trimEnd('/') + require(!host.startsWith("http://", ignoreCase = true)) { + "和风天气 API Host 必须使用 HTTPS" + } + return when { + host.startsWith("https://", ignoreCase = true) -> host + else -> "https://$host" + } + } + + private fun jwt(forceRefresh: Boolean): String = synchronized(jwtLock) { + val now = Instant.now().epochSecond + val privateKeyFile = JChatGPT.resolveConfigFile(PluginConfig.qWeatherPrivateKeyPath) + val config = listOf( + PluginConfig.qWeatherProjectId, + PluginConfig.qWeatherCredentialId, + privateKeyFile.absolutePath, + privateKeyFile.lastModified().toString() + ).joinToString("|") + + cachedJwt?.takeIf { + !forceRefresh && cachedJwtConfig == config && now < cachedJwtExpiresAt - JWT_REFRESH_AHEAD_SECONDS + }?.let { return@synchronized it } + + require(privateKeyFile.isFile) { "和风天气私钥文件不存在:${privateKeyFile.absolutePath}" } + val privateKeyPem = privateKeyFile.readText() + val privateKeyBase64 = privateKeyPem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .filterNot(Char::isWhitespace) + require(privateKeyBase64.isNotEmpty()) { "和风天气私钥文件内容为空" } + + val privateKey = try { + EdDSAPrivateKey(PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64))) + } catch (e: Throwable) { + throw IllegalArgumentException("无法读取和风天气 Ed25519 私钥:${privateKeyFile.absolutePath}", e) + } + + val issuedAt = now - 30 + val expiresAt = issuedAt + JWT_LIFETIME_SECONDS + val header = buildJsonObject { + put("alg", "EdDSA") + put("kid", PluginConfig.qWeatherCredentialId) + }.toString() + val payload = buildJsonObject { + put("sub", PluginConfig.qWeatherProjectId) + put("iat", issuedAt) + put("exp", expiresAt) + }.toString() + + val encoder = Base64.getUrlEncoder().withoutPadding() + val encodedHeader = encoder.encodeToString(header.toByteArray(StandardCharsets.UTF_8)) + val encodedPayload = encoder.encodeToString(payload.toByteArray(StandardCharsets.UTF_8)) + val signingInput = "$encodedHeader.$encodedPayload" + + val spec = EdDSANamedCurveTable.ED_25519_CURVE_SPEC + val signer = EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm)) + signer.initSign(privateKey) + signer.update(signingInput.toByteArray(StandardCharsets.UTF_8)) + val signature = encoder.encodeToString(signer.sign()) + + "$signingInput.$signature".also { + cachedJwt = it + cachedJwtExpiresAt = expiresAt + cachedJwtConfig = config + } + } + + private fun invalidateJwt() = synchronized(jwtLock) { + cachedJwt = null + cachedJwtExpiresAt = 0L + cachedJwtConfig = "" + } +}