diff --git a/.idea/claudeCodeTabState.xml b/.idea/claudeCodeTabState.xml
new file mode 100644
index 0000000..6028f85
--- /dev/null
+++ b/.idea/claudeCodeTabState.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt
index 740463a..c13e374 100644
--- a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt
+++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt
@@ -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");
}
// 基础获取方法
diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt
index 130d02d..5ad3489 100644
--- a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt
+++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt
@@ -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())
+ val enabledEvents = if (eventNames.isEmpty()) {
+ GiteaEventType.entries.toSet()
+ } else {
+ eventNames.mapNotNull { GiteaEventType.fromHeader(it) }.toSet()
+ }
+ return GiteaWebhookModule(
+ config.name,
+ port,
+ webhookPath,
+ secret,
+ targetGroupId,
+ enabledEvents
+ )
+ }
+
}
\ No newline at end of file
diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt
index 7ef961a..a66a301 100644
--- a/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt
+++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt
@@ -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
diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/gitea/GiteaEvent.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/gitea/GiteaEvent.kt
new file mode 100644
index 0000000..fb18841
--- /dev/null
+++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/gitea/GiteaEvent.kt
@@ -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 = 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) }
+ }
+}
diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/gitea/GiteaWebhookModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/gitea/GiteaWebhookModule.kt
new file mode 100644
index 0000000..a3b835c
--- /dev/null
+++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/gitea/GiteaWebhookModule.kt
@@ -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,
+) : 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()
+}