314 lines
12 KiB
Kotlin
314 lines
12 KiB
Kotlin
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()
|
||
}
|