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() +}