tools: integrate QWeather JWT weather service

Replace the unavailable weather endpoint with QWeather location, forecast, minutely precipitation, and alert APIs. Add configurable JWT credentials, automatic alert lookup, setup documentation, and bump the plugin to 1.13.0.
This commit is contained in:
2026-07-24 11:13:26 +08:00
parent 59a98830cd
commit 7b5a83ba9c
5 changed files with 306 additions and 26 deletions
+28 -1
View File
@@ -116,6 +116,14 @@ jinaApiKey: ''
searXngUrl: '' searXngUrl: ''
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。 # 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
glotToken: '' glotToken: ''
# 和风天气专属 API Host,可在和风天气控制台的设置页面查看
qWeatherApiHost: ''
# 和风天气项目 ID
qWeatherProjectId: ''
# 和风天气 JWT 凭据 ID
qWeatherCredentialId: ''
# Ed25519 私钥路径,相对于插件配置目录,也可以填写绝对路径
qWeatherPrivateKeyPath: 'qweather-ed25519-private.pem'
# 群管理是否自动拥有对话权限,默认是 # 群管理是否自动拥有对话权限,默认是
groupOpHasChatPermission: true groupOpHasChatPermission: true
# 好友是否自动拥有对话权限,默认是 # 好友是否自动拥有对话权限,默认是
@@ -160,6 +168,25 @@ searchHistoryMaxDays: 30
searchHistoryMaxRecords: 5000 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` 文件中。 JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件位于插件配置目录下的 `SystemPrompt.md` 文件中。
@@ -566,4 +593,4 @@ JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消
- 如果默认的 API 调用失败,可以更换为其他兼容的 API 地址 - 如果默认的 API 调用失败,可以更换为其他兼容的 API 地址
- 可根据需要配置代理设置 - 可根据需要配置代理设置
- 某些工具需要额外的 API 密钥才能启用 - 某些工具需要额外的 API 密钥才能启用
- 插件支持自定义系统提示词,可以通过修改 `SystemPrompt.md` 文件来实现 - 插件支持自定义系统提示词,可以通过修改 `SystemPrompt.md` 文件来实现
+4 -2
View File
@@ -7,7 +7,7 @@ plugins {
} }
group = "top.jie65535.mirai" group = "top.jie65535.mirai"
version = "1.12.0" version = "1.13.0"
mirai { mirai {
jvmTarget = JavaVersion.VERSION_11 jvmTarget = JavaVersion.VERSION_11
@@ -31,10 +31,12 @@ val jLatexMathVersion = "1.0.7"
val commonTextVersion = "1.13.0" val commonTextVersion = "1.13.0"
val hibernateVersion = "2.9.0" val hibernateVersion = "2.9.0"
val overflowVersion = "1.0.7" val overflowVersion = "1.0.7"
val eddsaVersion = "0.3.0"
dependencies { dependencies {
implementation("com.aallam.openai:openai-client:$openaiClientVersion") implementation("com.aallam.openai:openai-client:$openaiClientVersion")
implementation("io.ktor:ktor-client-okhttp:$ktorVersion") implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
implementation("net.i2p.crypto:eddsa:$eddsaVersion")
implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion") implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion")
implementation("org.apache.commons:commons-text:$commonTextVersion") implementation("org.apache.commons:commons-text:$commonTextVersion")
@@ -42,4 +44,4 @@ dependencies {
compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion") compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion")
testConsoleRuntime("top.mrxiaom.mirai:overflow-core:$overflowVersion") testConsoleRuntime("top.mrxiaom.mirai:overflow-core:$overflowVersion")
} }
+2 -2
View File
@@ -53,7 +53,7 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription( JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT", id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT", name = "J ChatGPT",
version = "1.12.0", version = "1.13.0",
) { ) {
author("jie65535") author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true) // dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
@@ -1192,4 +1192,4 @@ object JChatGPT : KotlinPlugin(
logger.info("好感度时间偏移处理完成") logger.info("好感度时间偏移处理完成")
} }
} }
+13 -1
View File
@@ -87,6 +87,18 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。") @ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
val glotToken: String by value("") 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("群管理是否自动拥有对话权限,默认是") @ValueDescription("群管理是否自动拥有对话权限,默认是")
val groupOpHasChatPermission: Boolean by value(true) val groupOpHasChatPermission: Boolean by value(true)
@@ -171,4 +183,4 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("聊天记录搜索最大查询条数,防止内存溢出") @ValueDescription("聊天记录搜索最大查询条数,防止内存溢出")
val searchHistoryMaxRecords: Int by value(5000) val searchHistoryMaxRecords: Int by value(5000)
} }
+259 -20
View File
@@ -4,27 +4,63 @@ import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters import com.aallam.openai.api.core.Parameters
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* 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 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( class WeatherService : BaseAgent(
tool = Tool.function( tool = Tool.function(
name = "queryWeather", name = "queryWeather",
description = "可用于查询某城市地区天气.", description = "查询指定地区的和风天气数据,包括实时天气、每日预报、逐小时预报、分钟级降水和正在生效的官方天气预警。" +
"普通天气查询也会同时返回当地正在生效的预警。",
parameters = Parameters.buildJsonObject { parameters = Parameters.buildJsonObject {
put("type", "object") put("type", "object")
putJsonObject("properties") { putJsonObject("properties") {
putJsonObject("city") { putJsonObject("city") {
put("type", "string") put("type", "string")
put("description", "城市地区,如\"深圳市\"") put("description", "城市、区县或地区名称,如\"深圳市\"\"深圳南山区\"")
} }
putJsonObject("time_range") { putJsonObject("adm") {
put("type", "string")
put("description", "可选的上级行政区名称,用于区分重名地区,如\"北京市\"\"广东省\"")
}
putJsonObject("query_type") {
put("type", "string") put("type", "string")
putJsonArray("enum") { putJsonArray("enum") {
add("day") add("now")
add("three") add("daily")
add("many") 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默认3dhourly默认24h")
} }
} }
putJsonArray("required") { 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 override val loadingMessage: String
get() = "观天中..." get() = "观天中..."
override suspend fun execute(args: JsonObject?): String { override suspend fun execute(args: JsonObject?): String {
requireNotNull(args) requireNotNull(args)
val city = args.getValue("city").jsonPrimitive.content val city = args.getValue("city").jsonPrimitive.content
val timeRange = args["time_range"]?.jsonPrimitive?.contentOrNull val adm = args["adm"]?.jsonPrimitive?.contentOrNull
val response = httpClient.get( val queryType = args["query_type"]?.jsonPrimitive?.contentOrNull ?: "now"
buildString { val range = args["range"]?.jsonPrimitive?.contentOrNull
append(when (timeRange) {
"many" -> "https://api.52vmy.cn/api/query/tian/many" require(queryType in setOf("now", "daily", "hourly", "minutely", "warning")) {
"three" -> "https://api.52vmy.cn/api/query/tian/three" "不支持的天气查询类型:$queryType"
else -> "https://api.52vmy.cn/api/query/tian" }
})
append("?city=") val location = resolveLocation(city, adm)
append(city) 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"))
}
} }
) val warningDeferred = async {
return response.bodyAsText() 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()
} }
}
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<String, String>): 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<String, String>,
forceRefreshJwt: Boolean
): Pair<HttpStatusCode, String> {
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 = ""
}
}