mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
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:
@@ -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` 文件来实现
|
||||
- 插件支持自定义系统提示词,可以通过修改 `SystemPrompt.md` 文件来实现
|
||||
|
||||
+4
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("好感度时间偏移处理完成")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user