LTD-ManaagerBot/src/main/kotlin/top/r3944realms/ltdmanager/module/gitea/GiteaWebhookModule.kt

314 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<GiteaEventType>,
) : 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<PushPayload>(body))
GiteaEventType.ISSUES -> formatIssues(json.decodeFromString<IssuesPayload>(body))
GiteaEventType.PULL_REQUEST -> formatPullRequest(json.decodeFromString<PullRequestPayload>(body))
GiteaEventType.CREATE -> formatCreate(json.decodeFromString<CreatePayload>(body))
GiteaEventType.DELETE -> formatDelete(json.decodeFromString<DeletePayload>(body))
GiteaEventType.RELEASE -> formatRelease(json.decodeFromString<ReleasePayload>(body))
GiteaEventType.REPOSITORY -> formatRepository(json.decodeFromString<RepositoryPayload>(body))
GiteaEventType.FORK -> formatFork(json.decodeFromString<ForkPayload>(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://<host>:$port$webhookPath
启用事件: ${enabledEvents.joinToString(", ") { it.headerValue }}
""".trimIndent()
}