diff --git a/README.md b/README.md index 2c3a23f..2014b26 100644 --- a/README.md +++ b/README.md @@ -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 限制。 ## 用户画像系统 diff --git a/src/main/kotlin/config/PluginConfig.kt b/src/main/kotlin/config/PluginConfig.kt index 297945a..58d95e7 100644 --- a/src/main/kotlin/config/PluginConfig.kt +++ b/src/main/kotlin/config/PluginConfig.kt @@ -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("") diff --git a/src/main/kotlin/conversation/ConversationEngine.kt b/src/main/kotlin/conversation/ConversationEngine.kt index e1e767e..cfed190 100644 --- a/src/main/kotlin/conversation/ConversationEngine.kt +++ b/src/main/kotlin/conversation/ConversationEngine.kt @@ -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(), diff --git a/src/main/kotlin/tools/GithubAgent.kt b/src/main/kotlin/tools/GithubAgent.kt new file mode 100644 index 0000000..89cd55d --- /dev/null +++ b/src/main/kotlin/tools/GithubAgent.kt @@ -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 = 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? { + 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? { + 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? { + 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 + } +} diff --git a/src/test/kotlin/tools/GithubAgentTest.kt b/src/test/kotlin/tools/GithubAgentTest.kt new file mode 100644 index 0000000..f007ab5 --- /dev/null +++ b/src/test/kotlin/tools/GithubAgentTest.kt @@ -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") + ) + ) + } +}