feat: 添加Gitea WebHook模块

This commit is contained in:
叁玖领域 2026-06-07 16:39:43 +08:00
parent 7c9e1b5b9a
commit 3f3196e5ac
6 changed files with 558 additions and 0 deletions

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ClaudeCodeTabState">
<option name="tabSessions">
<map>
<entry key="0">
<value>
<TabSessionState>
<option name="provider" value="claude" />
<option name="sessionId" value="15d84e94-4c9e-4c16-9632-6aa5b1977df6" />
<option name="cwd" value="$PROJECT_DIR$" />
<option name="model" value="claude-sonnet-4-6[1m]" />
<option name="permissionMode" value="bypassPermissions" />
<option name="reasoningEffort" value="high" />
</TabSessionState>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@ -124,6 +124,7 @@ data class ModuleConfig(
RCON_PLAYER_LIST_MODULE(Modules.RCON_PLAYER_LIST),
STATE_MODULE(Modules.STATE),
HELP_MODULE(Modules.HELP),
GITEA_WEBHOOK_MODULE(Modules.GITEA_WEBHOOK),
UNKNOWN_MODULE("UnknownModule");
}
// 基础获取方法

View File

@ -5,6 +5,8 @@ import top.r3944realms.ltdmanager.core.config.ModuleConfig
import top.r3944realms.ltdmanager.core.config.ModuleConfig.Module.ModuleType.*
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.module.exception.ConfigError
import top.r3944realms.ltdmanager.module.gitea.GiteaEventType
import top.r3944realms.ltdmanager.module.gitea.GiteaWebhookModule
object ModuleFactory {
fun createModule(config: ModuleConfig.Module): BaseModule {
@ -20,6 +22,7 @@ object ModuleFactory {
STATE_MODULE -> createState(config)
MOD_GROUP_HANDLER_MODULE -> createModGroupHandler(config)
HELP_MODULE -> createHelpModule(config)
GITEA_WEBHOOK_MODULE -> createGiteaWebhook(config)
UNKNOWN_MODULE -> throw ConfigError(ConfigError.Type.INVALID_PARAMETER, "unknown module")
}
}
@ -190,4 +193,25 @@ object ModuleFactory {
)
}
private fun createGiteaWebhook(config: ModuleConfig.Module): GiteaWebhookModule {
val port = config.int("webhook-port")
val webhookPath = config.getOrDefault("webhook-path", "/gitea-webhook")
val secret = config.getOrDefault("webhook-secret", "")
val targetGroupId = config.long("target-group-id")
val eventNames = config.getOrDefault("events", emptyList<String>())
val enabledEvents = if (eventNames.isEmpty()) {
GiteaEventType.entries.toSet()
} else {
eventNames.mapNotNull { GiteaEventType.fromHeader(it) }.toSet()
}
return GiteaWebhookModule(
config.name,
port,
webhookPath,
secret,
targetGroupId,
enabledEvents
)
}
}

View File

@ -16,6 +16,7 @@ object Modules {
val RCON_PLAYER_LIST: String = register("RconPlayerListModule")
val INVITATION_CODE: String = register("InvitationCodeModule")
val STATE: String = register("StateModule")
val GITEA_WEBHOOK: String = register("GiteaWebhookModule")
fun register(name: String): String {
MODULES.add(name)
return name

View File

@ -0,0 +1,198 @@
package top.r3944realms.ltdmanager.module.gitea
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GiteaUser(
val id: Long = 0,
val login: String = "",
@SerialName("login_name") val loginName: String = "",
@SerialName("full_name") val fullName: String = "",
val email: String = "",
@SerialName("avatar_url") val avatarUrl: String = "",
val username: String = "",
)
@Serializable
data class GiteaRepository(
val id: Long = 0,
val name: String = "",
@SerialName("full_name") val fullName: String = "",
val owner: GiteaUser = GiteaUser(),
@SerialName("html_url") val htmlUrl: String = "",
val description: String = "",
@SerialName("ssh_url") val sshUrl: String = "",
@SerialName("clone_url") val cloneUrl: String = "",
@SerialName("default_branch") val defaultBranch: String = "",
val private: Boolean = false,
val website: String = "",
)
@Serializable
data class GiteaCommit(
val id: String = "",
val message: String = "",
val url: String = "",
val author: GiteaUser = GiteaUser(),
val committer: GiteaUser = GiteaUser(),
val timestamp: String = "",
)
@Serializable
data class GiteaIssue(
val id: Long = 0,
val number: Long = 0,
val title: String = "",
val body: String = "",
val state: String = "",
@SerialName("html_url") val htmlUrl: String = "",
val user: GiteaUser = GiteaUser(),
val assignee: GiteaUser? = null,
@SerialName("is_pull") val isPull: Boolean = false,
val comments: Long = 0,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
)
@Serializable
data class GiteaPullRequest(
val id: Long = 0,
val number: Long = 0,
val title: String = "",
val body: String = "",
val state: String = "",
val merged: Boolean = false,
@SerialName("mergeable") val mergeable: Boolean? = null,
@SerialName("html_url") val htmlUrl: String = "",
val user: GiteaUser = GiteaUser(),
@SerialName("head") val headBranch: GiteaBranchRef = GiteaBranchRef(),
@SerialName("base") val baseBranch: GiteaBranchRef = GiteaBranchRef(),
@SerialName("merged_by") val mergedBy: GiteaUser? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
)
@Serializable
data class GiteaBranchRef(
val label: String = "",
val ref: String = "",
val sha: String = "",
val repo: GiteaRepository = GiteaRepository(),
)
@Serializable
data class GiteaRelease(
val id: Long = 0,
@SerialName("tag_name") val tagName: String = "",
@SerialName("target_commitish") val targetCommitish: String = "",
val name: String = "",
val body: String = "",
val draft: Boolean = false,
val prerelease: Boolean = false,
val url: String = "",
@SerialName("html_url") val htmlUrl: String = "",
val author: GiteaUser = GiteaUser(),
@SerialName("created_at") val createdAt: String = "",
)
@Serializable
data class GiteaSender(
val id: Long = 0,
val login: String = "",
@SerialName("login_name") val loginName: String = "",
@SerialName("full_name") val fullName: String = "",
val email: String = "",
@SerialName("avatar_url") val avatarUrl: String = "",
)
// --- Event payloads ---
@Serializable
data class PushPayload(
val ref: String = "",
val before: String = "",
val after: String = "",
@SerialName("compare_url") val compareUrl: String = "",
val commits: List<GiteaCommit> = emptyList(),
@SerialName("head_commit") val headCommit: GiteaCommit? = null,
val pusher: GiteaUser? = null,
val sender: GiteaUser = GiteaUser(),
val repository: GiteaRepository = GiteaRepository(),
@SerialName("total_commits") val totalCommits: Int = 0,
)
@Serializable
data class IssuesPayload(
val action: String = "",
val issue: GiteaIssue = GiteaIssue(),
val sender: GiteaUser = GiteaUser(),
val repository: GiteaRepository = GiteaRepository(),
)
@Serializable
data class PullRequestPayload(
val action: String = "",
val number: Long = 0,
@SerialName("pull_request") val pullRequest: GiteaPullRequest = GiteaPullRequest(),
val sender: GiteaUser = GiteaUser(),
val repository: GiteaRepository = GiteaRepository(),
)
@Serializable
data class CreatePayload(
val ref: String = "",
@SerialName("ref_type") val refType: String = "",
val sha: String = "",
val sender: GiteaUser = GiteaUser(),
val repository: GiteaRepository = GiteaRepository(),
)
@Serializable
data class DeletePayload(
val ref: String = "",
@SerialName("ref_type") val refType: String = "",
val sha: String = "",
val sender: GiteaUser = GiteaUser(),
val repository: GiteaRepository = GiteaRepository(),
)
@Serializable
data class ReleasePayload(
val action: String = "",
val release: GiteaRelease = GiteaRelease(),
val sender: GiteaUser = GiteaUser(),
val repository: GiteaRepository = GiteaRepository(),
)
@Serializable
data class RepositoryPayload(
val action: String = "",
val repository: GiteaRepository = GiteaRepository(),
val sender: GiteaUser = GiteaUser(),
)
@Serializable
data class ForkPayload(
@SerialName("forkee") val forkedRepo: GiteaRepository = GiteaRepository(),
val repository: GiteaRepository = GiteaRepository(),
val sender: GiteaUser = GiteaUser(),
)
// --- Event type enum for routing ---
enum class GiteaEventType(val headerValue: String) {
PUSH("push"),
ISSUES("issues"),
PULL_REQUEST("pull_request"),
CREATE("create"),
DELETE("delete"),
RELEASE("release"),
REPOSITORY("repository"),
FORK("fork");
companion object {
fun fromHeader(value: String?): GiteaEventType? =
entries.find { it.headerValue.equals(value, ignoreCase = true) }
}
}

View File

@ -0,0 +1,313 @@
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()
}