mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
github: add read-only gh CLI tool
This commit is contained in:
@@ -33,6 +33,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
|
||||
### 工具调用
|
||||
AI 可以自动调用多种工具来完成复杂任务:
|
||||
- 网络搜索(需要配置 SearXNG)
|
||||
- GitHub 仓库、代码、Issue 和 PR 搜索(需要安装 `gh` 并配置只读 Token)
|
||||
- 代码执行(支持多种语言,需要配置 glot.io token)
|
||||
- 图像识别(需要配置视觉模型)
|
||||
- 推理思考(需要配置推理模型)
|
||||
@@ -175,6 +176,10 @@ jinaApiKey: ''
|
||||
jinaReaderUrl: 'https://r.jina.ai/'
|
||||
# SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回
|
||||
searXngUrl: ''
|
||||
# GitHub CLI 可执行文件路径;默认从系统 PATH 查找 gh
|
||||
githubCliPath: 'gh'
|
||||
# GitHub fine-grained 只读 Token;留空时禁用 GitHub 工具
|
||||
githubToken: ''
|
||||
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
|
||||
glotToken: ''
|
||||
# 和风天气专属 API Host,可在和风天气控制台的设置页面查看
|
||||
@@ -480,18 +485,31 @@ fallbackCooldownMinutes: 5
|
||||
插件内置了丰富的工具供 AI 调用:
|
||||
|
||||
1. **WebSearch** - 使用 SearXNG 进行网络搜索
|
||||
2. **RunCode** - 在 glot.io 上执行多种编程语言代码
|
||||
3. **VisualAgent** - 图像识别和理解
|
||||
4. **ReasoningAgent** - 深度思考和推理
|
||||
5. **MemoryAppend/Replace** - 对话记忆管理
|
||||
6. **LoadSkill/SaveSkill/DeleteSkill** - 技能管理(加载、沉淀/迭代、删除全局技能)
|
||||
7. **GroupManageAgent** - 群管理功能(如禁言)
|
||||
8. **SendSingleMessage/CompositeMessage** - 发送消息
|
||||
9. **SendVoiceMessage** - 发送语音消息
|
||||
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
||||
11. **WeatherService** - 天气查询
|
||||
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史
|
||||
13. **QueryUserProfile** - 按当前发送者、QQ 号、群名片、昵称或好友备注读取长期画像,并针对该联系人按需读取公开资料卡
|
||||
2. **GithubAgent** - 使用认证的 `gh` 搜索和只读访问 GitHub 仓库、代码、Issue 与 PR
|
||||
3. **RunCode** - 在 glot.io 上执行多种编程语言代码
|
||||
4. **VisualAgent** - 图像识别和理解
|
||||
5. **ReasoningAgent** - 深度思考和推理
|
||||
6. **MemoryAppend/Replace** - 对话记忆管理
|
||||
7. **LoadSkill/SaveSkill/DeleteSkill** - 技能管理(加载、沉淀/迭代、删除全局技能)
|
||||
8. **GroupManageAgent** - 群管理功能(如禁言)
|
||||
9. **SendSingleMessage/CompositeMessage** - 发送消息
|
||||
10. **SendVoiceMessage** - 发送语音消息
|
||||
11. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
||||
12. **WeatherService** - 天气查询
|
||||
13. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史
|
||||
14. **QueryUserProfile** - 按当前发送者、QQ 号、群名片、昵称或好友备注读取长期画像,并针对该联系人按需读取公开资料卡
|
||||
|
||||
### GitHub 查询工具
|
||||
|
||||
在机器人运行环境安装 [GitHub CLI](https://cli.github.com/),并在 `githubToken` 中配置 fine-grained PAT。
|
||||
建议只授予所需仓库的 `Metadata: Read`、`Contents: Read`、`Issues: Read` 和
|
||||
`Pull requests: Read` 权限。插件通过子进程环境变量 `GH_TOKEN` 传递 Token,不需要执行 `gh auth login`,
|
||||
也不会把 Token 放进命令参数或日志。
|
||||
|
||||
模型通过统一的 `github` 工具多轮调用 `gh`,可使用 `search repos/code/issues/prs/commits`、
|
||||
`repo view`、`issue list/view`、`pr list/view/diff/checks`、`release list/view` 和只读 REST `api`。
|
||||
插件不提供 Shell,并拒绝写操作、登录、扩展、本地仓库操作、非 GitHub endpoint、超大查询和长时间运行。
|
||||
认证请求仍受 GitHub API、Search API 和 secondary rate limit 限制。
|
||||
|
||||
## 用户画像系统
|
||||
|
||||
|
||||
@@ -192,6 +192,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回")
|
||||
val searXngUrl: String by value("")
|
||||
|
||||
@ValueDescription("GitHub CLI 可执行文件路径;默认从系统 PATH 查找 gh")
|
||||
val githubCliPath: String by value("gh")
|
||||
|
||||
@ValueDescription("GitHub fine-grained 只读 Token;留空时禁用 GitHub 工具,不会写入 GitHub 数据")
|
||||
val githubToken: String by value("")
|
||||
|
||||
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
|
||||
val glotToken: String by value("")
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import top.jie65535.mirai.tools.AdjustUserFavorabilityAgent
|
||||
import top.jie65535.mirai.tools.BaseAgent
|
||||
import top.jie65535.mirai.tools.DeleteSkill
|
||||
import top.jie65535.mirai.tools.GroupManageAgent
|
||||
import top.jie65535.mirai.tools.GithubAgent
|
||||
import top.jie65535.mirai.tools.ImageAgent
|
||||
import top.jie65535.mirai.tools.LoadSkill
|
||||
import top.jie65535.mirai.tools.MemoryAppend
|
||||
@@ -71,6 +72,7 @@ internal object ConversationEngine {
|
||||
SearchChatHistory(),
|
||||
QueryUserProfileAgent(),
|
||||
WebSearch(),
|
||||
GithubAgent(),
|
||||
VisitWeb(),
|
||||
RunCode(),
|
||||
ReasoningAgent(),
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.*
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Provides a single, authenticated, read-only entry point to the GitHub CLI.
|
||||
*
|
||||
* The model can use the breadth of gh's query commands without receiving a
|
||||
* general shell. Only read-oriented command families are accepted below.
|
||||
*/
|
||||
class GithubAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "github",
|
||||
description = "使用已认证的 GitHub CLI 只读查询 GitHub。参数 args 是 gh 命令参数数组,不要包含 gh 本身。" +
|
||||
"支持 search repos/code/issues/prs/commits、repo view、issue list/view、" +
|
||||
"pr list/view/diff/checks、release list/view,以及只读 gh api。" +
|
||||
"可通过多轮调用先搜索,再查看仓库、文件、Issue 或 PR;优先使用 --json。" +
|
||||
"禁止写入、登录、安装扩展、执行本地代码或访问非 GitHub API。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("args") {
|
||||
put("type", "array")
|
||||
put("minItems", 1)
|
||||
put("maxItems", MAX_ARGUMENTS)
|
||||
putJsonObject("items") {
|
||||
put("type", "string")
|
||||
}
|
||||
put(
|
||||
"description",
|
||||
"gh 命令参数数组,不含 gh。例如 [\"search\", \"code\", \"ChatCompletion language:kotlin\", \"--limit\", \"20\"]"
|
||||
)
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("args")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.githubToken.isNotBlank() && PluginConfig.githubCliPath.isNotBlank()
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "查询 GitHub 中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
val argsJson = args["args"] as? JsonArray
|
||||
?: return "GitHub 工具参数错误:args 必须是字符串数组"
|
||||
if (argsJson.isEmpty()) return "GitHub 工具参数错误:args 不能为空"
|
||||
|
||||
val cliArgs = buildList {
|
||||
argsJson.forEachIndexed { index, value ->
|
||||
val primitive = value as? JsonPrimitive
|
||||
?: return "GitHub 工具参数错误:args[$index] 必须是字符串"
|
||||
if (!primitive.isString) {
|
||||
return "GitHub 工具参数错误:args[$index] 必须是字符串"
|
||||
}
|
||||
val content = primitive.contentOrNull
|
||||
?: return "GitHub 工具参数错误:args[$index] 必须是字符串"
|
||||
add(content)
|
||||
}
|
||||
}
|
||||
|
||||
val validationError = validateReadOnlyArgs(cliArgs)
|
||||
if (validationError != null) return "GitHub 工具拒绝执行:$validationError"
|
||||
|
||||
return runCli(cliArgs)
|
||||
}
|
||||
|
||||
private suspend fun runCli(args: List<String>): String = withContext(Dispatchers.IO) {
|
||||
val executable = PluginConfig.githubCliPath.trim()
|
||||
val process = try {
|
||||
ProcessBuilder(listOf(executable) + args)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment()["GH_TOKEN"] = PluginConfig.githubToken
|
||||
environment()["GH_PROMPT_DISABLED"] = "1"
|
||||
environment()["GH_PAGER"] = ""
|
||||
environment()["NO_COLOR"] = "1"
|
||||
}
|
||||
.start()
|
||||
} catch (e: IOException) {
|
||||
return@withContext "无法启动 GitHub CLI,请检查 githubCliPath 和 gh 安装:${e.message}"
|
||||
}
|
||||
process.outputStream.close()
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
val truncated = AtomicBoolean(false)
|
||||
val reader = thread(start = true, isDaemon = true, name = "jchatgpt-github-output") {
|
||||
val buffer = ByteArray(8192)
|
||||
var totalBytes = 0
|
||||
try {
|
||||
while (true) {
|
||||
val count = process.inputStream.read(buffer)
|
||||
if (count < 0) break
|
||||
val remaining = MAX_OUTPUT_BYTES - totalBytes
|
||||
if (remaining > 0) {
|
||||
val accepted = minOf(count, remaining)
|
||||
output.write(buffer, 0, accepted)
|
||||
totalBytes += accepted
|
||||
}
|
||||
if (totalBytes >= MAX_OUTPUT_BYTES) {
|
||||
truncated.set(true)
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// The process may close its stream while being terminated for a timeout or output cap.
|
||||
}
|
||||
}
|
||||
|
||||
val finished = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
reader.join(READER_JOIN_MILLIS)
|
||||
return@withContext "GitHub CLI 执行超时(超过 ${PROCESS_TIMEOUT_SECONDS} 秒)"
|
||||
}
|
||||
|
||||
reader.join(READER_JOIN_MILLIS)
|
||||
val result = output.toString(StandardCharsets.UTF_8.name()).trim()
|
||||
val suffix = if (truncated.get()) {
|
||||
"\n\n[GitHub CLI 输出已截断,原始结果超过 ${MAX_OUTPUT_BYTES} 字节]"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (process.exitValue() == 0 || truncated.get()) {
|
||||
(result.ifEmpty { "GitHub CLI 未返回内容" }) + suffix
|
||||
} else {
|
||||
"GitHub CLI 执行失败(退出码 ${process.exitValue()}):\n" +
|
||||
(result.ifEmpty { "未返回错误信息" }) + suffix
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val MAX_ARGUMENTS = 48
|
||||
internal const val MAX_ARGUMENT_LENGTH = 1000
|
||||
internal const val MAX_TOTAL_ARGUMENT_LENGTH = 8000
|
||||
internal const val MAX_OUTPUT_BYTES = 512 * 1024
|
||||
private const val PROCESS_TIMEOUT_SECONDS = 30L
|
||||
private const val READER_JOIN_MILLIS = 2000L
|
||||
|
||||
private val searchCommands = setOf("repos", "code", "issues", "prs", "commits")
|
||||
private val readSubcommands = mapOf(
|
||||
"repo" to setOf("view"),
|
||||
"issue" to setOf("list", "view"),
|
||||
"pr" to setOf("list", "view", "diff", "checks"),
|
||||
"release" to setOf("list", "view"),
|
||||
)
|
||||
private val blockedArguments = setOf(
|
||||
"--web",
|
||||
"--hostname",
|
||||
"--paginate",
|
||||
"--slurp",
|
||||
"--method",
|
||||
"-X",
|
||||
"--input",
|
||||
"--raw-field",
|
||||
"--field",
|
||||
"-f",
|
||||
"-F",
|
||||
"--header",
|
||||
"-H",
|
||||
"--cache",
|
||||
)
|
||||
|
||||
/** Returns null for an accepted read-only command, otherwise a user-readable rejection reason. */
|
||||
internal fun validateReadOnlyArgs(args: List<String>): String? {
|
||||
if (args.isEmpty()) return "args 不能为空"
|
||||
if (args.size > MAX_ARGUMENTS) return "参数数量不能超过 $MAX_ARGUMENTS"
|
||||
if (args.any { it.isEmpty() }) return "参数不能包含空字符串"
|
||||
if (args.any { it.length > MAX_ARGUMENT_LENGTH }) {
|
||||
return "单个参数长度不能超过 $MAX_ARGUMENT_LENGTH 个字符"
|
||||
}
|
||||
if (args.sumOf { it.length } > MAX_TOTAL_ARGUMENT_LENGTH) {
|
||||
return "参数总长度不能超过 $MAX_TOTAL_ARGUMENT_LENGTH 个字符"
|
||||
}
|
||||
if (args.any { it.any(Char::isISOControl) }) return "参数不能包含控制字符"
|
||||
|
||||
val blocked = args.firstOrNull { argument ->
|
||||
val option = argument.substringBefore('=')
|
||||
option in blockedArguments
|
||||
}
|
||||
if (blocked != null) return "不允许使用参数 $blocked"
|
||||
|
||||
val accepted = when (args.first()) {
|
||||
"help" -> args.size <= 2
|
||||
"search" -> args.getOrNull(1) in searchCommands
|
||||
else -> readSubcommands[args.first()]?.contains(args.getOrNull(1)) == true
|
||||
}
|
||||
if (!accepted && args.first() != "api") {
|
||||
return "只允许 GitHub 搜索和读取类 gh 子命令"
|
||||
}
|
||||
|
||||
validateLimit(args)?.let { return it }
|
||||
if (args.first() == "api") return validateApiArgs(args)
|
||||
return null
|
||||
}
|
||||
|
||||
private fun validateLimit(args: List<String>): String? {
|
||||
args.forEachIndexed { index, argument ->
|
||||
if (argument == "--limit") {
|
||||
val value = args.getOrNull(index + 1)
|
||||
?: return "--limit 缺少数值"
|
||||
val limit = value.toIntOrNull()
|
||||
?: return "--limit 必须是整数"
|
||||
if (limit !in 1..MAX_RESULTS) return "--limit 必须在 1 到 $MAX_RESULTS 之间"
|
||||
} else if (argument.startsWith("--limit=")) {
|
||||
val limit = argument.substringAfter('=').toIntOrNull()
|
||||
?: return "--limit 必须是整数"
|
||||
if (limit !in 1..MAX_RESULTS) return "--limit 必须在 1 到 $MAX_RESULTS 之间"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun validateApiArgs(args: List<String>): String? {
|
||||
val endpoint = args.getOrNull(1)
|
||||
?: return "gh api 需要提供 endpoint"
|
||||
if (endpoint.startsWith("-") || endpoint.contains("://") || endpoint == "graphql") {
|
||||
return "gh api 仅允许访问 GitHub REST endpoint"
|
||||
}
|
||||
if (!endpoint.startsWith("repos/") &&
|
||||
!endpoint.startsWith("search/") &&
|
||||
endpoint != "user" &&
|
||||
endpoint != "rate_limit"
|
||||
) {
|
||||
return "gh api 仅允许 repos、search、user 和 rate_limit endpoint"
|
||||
}
|
||||
|
||||
var index = 2
|
||||
while (index < args.size) {
|
||||
when (args[index]) {
|
||||
"--jq", "--template" -> {
|
||||
if (args.getOrNull(index + 1).isNullOrBlank()) {
|
||||
return "${args[index]} 缺少表达式"
|
||||
}
|
||||
index += 2
|
||||
}
|
||||
|
||||
else -> return "gh api 只允许 endpoint、--jq 和 --template"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private const val MAX_RESULTS = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class GithubAgentTest {
|
||||
@Test
|
||||
fun `common search and read commands are accepted`() {
|
||||
assertNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("search", "code", "ChatCompletion language:kotlin", "--limit", "20", "--json", "repository,path,url")
|
||||
)
|
||||
)
|
||||
assertNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("pr", "view", "123", "--repo", "owner/repo", "--json", "title,body,comments")
|
||||
)
|
||||
)
|
||||
assertNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("api", "repos/owner/repo/pulls/123/files", "--jq", ".[].[filename,status]")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write and local state commands are rejected`() {
|
||||
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("issue", "create", "--title", "test")))
|
||||
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("pr", "merge", "123")))
|
||||
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("repo", "clone", "owner/repo")))
|
||||
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("auth", "token")))
|
||||
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("extension", "install", "owner/extension")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `api remains read only and limited to github rest endpoints`() {
|
||||
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "graphql")))
|
||||
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "https://example.com/private")))
|
||||
assertNotNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("api", "repos/owner/repo/issues", "--method", "POST")
|
||||
)
|
||||
)
|
||||
assertNotNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("api", "orgs/owner/repos")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `result limits and unsafe global flags are rejected`() {
|
||||
assertNotNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("search", "issues", "bug", "--limit", "51")
|
||||
)
|
||||
)
|
||||
assertNotNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("repo", "view", "owner/repo", "--web")
|
||||
)
|
||||
)
|
||||
assertNotNull(
|
||||
GithubAgent.validateReadOnlyArgs(
|
||||
listOf("search", "repos", "kotlin", "--hostname=example.com")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user