package top.r3944realms.ltdmanager.module.gitea import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import top.r3944realms.ltdmanager.module.BaseModule import top.r3944realms.ltdmanager.module.Modules import top.r3944realms.ltdmanager.napcat.data.ID import top.r3944realms.ltdmanager.napcat.data.MessageElement import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest import top.r3944realms.ltdmanager.utils.LoggerUtil import java.net.InetSocketAddress import java.util.concurrent.Executors import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec class GiteaWebhookModule( moduleName: String, private val port: Int, private val webhookPath: String, private val secret: String, private val targetGroupId: Long, private val enabledEvents: Set, ) : BaseModule(Modules.GITEA_WEBHOOK, moduleName) { private var server: HttpServer? = null private var scope: CoroutineScope? = null private val json = Json { ignoreUnknownKeys = true coerceInputValues = true } override fun onLoad() { scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope?.launch { try { startServer() } catch (e: Exception) { LoggerUtil.logger.error("[$name] 启动Webhook服务器失败", e) } } LoggerUtil.logger.info("[$name] Gitea Webhook模块已加载 (端口: $port, 路径: $webhookPath)") } override suspend fun onUnload() { server?.stop(0) server = null scope?.cancel() LoggerUtil.logger.info("[$name] Gitea Webhook模块已卸载") } private fun startServer() { var executor = Executors.newFixedThreadPool(2) server = HttpServer.create(InetSocketAddress(port), 0).apply { createContext(webhookPath) { exchange -> scope?.launch { handleRequest(exchange) } } start() } LoggerUtil.logger.info("[$name] Webhook服务器已启动在端口 $port$webhookPath") } private suspend fun handleRequest(exchange: HttpExchange) { try { if (exchange.requestMethod != "POST") { exchange.sendResponseHeaders(405, -1) exchange.close() return } val body = exchange.requestBody.bufferedReader().readText() val signatureHeader = exchange.requestHeaders.getFirst("X-Gitea-Signature") ?: "" val eventTypeHeader = exchange.requestHeaders.getFirst("X-Gitea-Event") ?: "" // 验证签名 if (!verifySignature(body, signatureHeader)) { LoggerUtil.logger.warn("[$name] 签名验证失败") exchange.sendResponseHeaders(403, -1) exchange.close() return } val eventType = GiteaEventType.fromHeader(eventTypeHeader) if (eventType == null) { LoggerUtil.logger.warn("[$name] 未知事件类型: $eventTypeHeader") exchange.sendResponseHeaders(200, -1) exchange.close() return } if (eventType !in enabledEvents) { LoggerUtil.logger.debug("[$name] 跳过未启用的事件: $eventType") exchange.sendResponseHeaders(200, -1) exchange.close() return } val message = formatEvent(eventType, body) if (message != null) { napCatClient.sendUnit( SendGroupMsgRequest( listOf(MessageElement.text(message)), ID.long(targetGroupId) ) ) } exchange.sendResponseHeaders(200, -1) exchange.close() } catch (e: Exception) { LoggerUtil.logger.error("[$name] 处理Webhook请求失败", e) try { exchange.sendResponseHeaders(500, -1) exchange.close() } catch (_: Exception) {} } } private fun verifySignature(payload: String, signatureHeader: String): Boolean { if (secret.isEmpty()) return true val expectedPrefix = "sha256=" if (!signatureHeader.startsWith(expectedPrefix)) return false val receivedHex = signatureHeader.removePrefix(expectedPrefix) val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) val computedHex = mac.doFinal(payload.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } return computedHex.equals(receivedHex, ignoreCase = true) } private fun formatEvent(eventType: GiteaEventType, body: String): String? { return try { when (eventType) { GiteaEventType.PUSH -> formatPush(json.decodeFromString(body)) GiteaEventType.ISSUES -> formatIssues(json.decodeFromString(body)) GiteaEventType.PULL_REQUEST -> formatPullRequest(json.decodeFromString(body)) GiteaEventType.CREATE -> formatCreate(json.decodeFromString(body)) GiteaEventType.DELETE -> formatDelete(json.decodeFromString(body)) GiteaEventType.RELEASE -> formatRelease(json.decodeFromString(body)) GiteaEventType.REPOSITORY -> formatRepository(json.decodeFromString(body)) GiteaEventType.FORK -> formatFork(json.decodeFromString(body)) } } catch (e: Exception) { LoggerUtil.logger.error("[$name] 解析Webhook事件失败 (type=$eventType)", e) null } } private fun formatPush(p: PushPayload): String { val repo = p.repository.fullName val branch = p.ref.removePrefix("refs/heads/").removePrefix("refs/tags/") val pusher = p.pusher?.login?.ifEmpty { p.pusher?.fullName } ?: p.sender.login.ifEmpty { p.sender.fullName } val count = p.totalCommits val sb = StringBuilder() sb.appendLine("🔨 [$repo] Push 事件") sb.appendLine("👤 $pusher 推送了 $count 个提交到 $branch") if (p.commits.isNotEmpty()) { sb.appendLine("————————————") for (commit in p.commits.take(5)) { val shortSha = commit.id.take(7) val msg = commit.message.lines().first().take(50) sb.appendLine("• $shortSha $msg") } if (p.commits.size > 5) { sb.appendLine("• ... 还有 ${p.commits.size - 5} 个提交") } } if (p.compareUrl.isNotEmpty()) { sb.appendLine("————————————") sb.append("查看: ${p.compareUrl}") } return sb.toString() } private fun formatIssues(p: IssuesPayload): String { val repo = p.repository.fullName val i = p.issue val user = p.sender.login.ifEmpty { p.sender.fullName } val action = translateAction(p.action) val sb = StringBuilder() sb.appendLine("📋 [$repo] Issue $action") sb.appendLine("👤 $user $action Issue #${i.number}: ${i.title}") if (i.body.isNotEmpty()) { sb.appendLine("————————————") sb.append(i.body.take(100).replace("\n", " ")) } sb.appendLine() sb.append("查看: ${i.htmlUrl}") return sb.toString() } private fun formatPullRequest(p: PullRequestPayload): String { val repo = p.repository.fullName val pr = p.pullRequest val user = p.sender.login.ifEmpty { p.sender.fullName } val action = translateAction(p.action) val head = pr.headBranch.label.ifEmpty { pr.headBranch.ref } val base = pr.baseBranch.label.ifEmpty { pr.baseBranch.ref } val sb = StringBuilder() sb.appendLine("🔀 [$repo] PR $action") sb.appendLine("👤 $user $action PR #${pr.number}: ${pr.title}") sb.appendLine("$head → $base") if (pr.body.isNotEmpty()) { sb.appendLine("————————————") sb.appendLine(pr.body.take(150).replace("\n", " ")) } sb.appendLine("————————————") val stateStr = when { pr.merged -> "已合并" pr.state == "closed" -> "已关闭" else -> "进行中" } sb.append("状态: $stateStr | 查看: ${pr.htmlUrl}") return sb.toString() } private fun formatCreate(p: CreatePayload): String { val repo = p.repository.fullName val user = p.sender.login.ifEmpty { p.sender.fullName } val typeName = when (p.refType) { "branch" -> "分支" "tag" -> "标签" else -> p.refType } return "➕ [$repo] 创建了$typeName: $p.ref\n👤 $user" } private fun formatDelete(p: DeletePayload): String { val repo = p.repository.fullName val user = p.sender.login.ifEmpty { p.sender.fullName } val typeName = when (p.refType) { "branch" -> "分支" "tag" -> "标签" else -> p.refType } return "❌ [$repo] 删除了$typeName: $p.ref\n👤 $user" } private fun formatRelease(p: ReleasePayload): String { val repo = p.repository.fullName val r = p.release val author = r.author.login.ifEmpty { r.author.fullName } val action = translateAction(p.action) val sb = StringBuilder() sb.appendLine("📦 [$repo] Release $action") sb.appendLine("🏷 ${r.tagName} - ${r.name.ifEmpty { r.tagName }}") sb.appendLine("👤 $author") if (r.body.isNotEmpty()) { sb.appendLine("————————————") sb.appendLine(r.body.take(200).replace("\n", " ")) } sb.appendLine("————————————") val flags = listOfNotNull( if (r.draft) "草稿" else null, if (r.prerelease) "预发布" else null ) if (flags.isNotEmpty()) sb.appendLine("标记: ${flags.joinToString(", ")}") sb.append("查看: ${r.htmlUrl}") return sb.toString() } private fun formatRepository(p: RepositoryPayload): String { val repo = p.repository.fullName val user = p.sender.login.ifEmpty { p.sender.fullName } val action = translateAction(p.action) return "📁 [$repo] Repository $action\n👤 $user" } private fun formatFork(p: ForkPayload): String { val source = p.repository.fullName val fork = p.forkedRepo.fullName val user = p.sender.login.ifEmpty { p.sender.fullName } return "🍴 [$source] Fork\n👤 $user Fork 了仓库 → $fork\n查看: ${p.forkedRepo.htmlUrl}" } private fun translateAction(action: String): String = when (action.lowercase()) { "opened" -> "已创建" "closed" -> "已关闭" "reopened" -> "已重新打开" "edited" -> "已编辑" "published" -> "已发布" "updated" -> "已更新" "created" -> "已创建" "deleted" -> "已删除" "labeled" -> "已标记" "unlabeled" -> "已取消标记" "assigned" -> "已指派" "unassigned" -> "已取消指派" "milestoned" -> "已设里程碑" "synchronized" -> "已同步" "review_requested" -> "请求审查" "transferred" -> "已转移" "merged" -> "已合并" else -> action } override fun info(): String = "Gitea Webhook模块 - 监听端口$port, 接收${enabledEvents.size}种事件类型" override fun help(): String = """ Gitea Webhook模块 - 接收Gitea Webhook事件并推送到QQ群 监听地址: http://:$port$webhookPath 启用事件: ${enabledEvents.joinToString(", ") { it.headerValue }} """.trimIndent() }