commit e224dc91a7e2c17d197cd1497006f4a62f81f176 Author: Kilka_v_HJlebe Date: Sun Aug 2 22:23:18 2026 +0300 nu i xuinya diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c748dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +run diff --git a/.gradle/9.4.0/checksums/checksums.lock b/.gradle/9.4.0/checksums/checksums.lock new file mode 100644 index 0000000..ec7acfb Binary files /dev/null and b/.gradle/9.4.0/checksums/checksums.lock differ diff --git a/.gradle/9.4.0/executionHistory/executionHistory.bin b/.gradle/9.4.0/executionHistory/executionHistory.bin new file mode 100644 index 0000000..20c175a Binary files /dev/null and b/.gradle/9.4.0/executionHistory/executionHistory.bin differ diff --git a/.gradle/9.4.0/executionHistory/executionHistory.lock b/.gradle/9.4.0/executionHistory/executionHistory.lock new file mode 100644 index 0000000..0b1e031 Binary files /dev/null and b/.gradle/9.4.0/executionHistory/executionHistory.lock differ diff --git a/.gradle/9.4.0/fileChanges/last-build.bin b/.gradle/9.4.0/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/9.4.0/fileChanges/last-build.bin differ diff --git a/.gradle/9.4.0/fileHashes/fileHashes.bin b/.gradle/9.4.0/fileHashes/fileHashes.bin new file mode 100644 index 0000000..9b84ee3 Binary files /dev/null and b/.gradle/9.4.0/fileHashes/fileHashes.bin differ diff --git a/.gradle/9.4.0/fileHashes/fileHashes.lock b/.gradle/9.4.0/fileHashes/fileHashes.lock new file mode 100644 index 0000000..2676ab5 Binary files /dev/null and b/.gradle/9.4.0/fileHashes/fileHashes.lock differ diff --git a/.gradle/9.4.0/gc.properties b/.gradle/9.4.0/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/9.6.1/checksums/checksums.lock b/.gradle/9.6.1/checksums/checksums.lock new file mode 100644 index 0000000..1c60718 Binary files /dev/null and b/.gradle/9.6.1/checksums/checksums.lock differ diff --git a/.gradle/9.6.1/checksums/md5-checksums.bin b/.gradle/9.6.1/checksums/md5-checksums.bin new file mode 100644 index 0000000..71d1eb2 Binary files /dev/null and b/.gradle/9.6.1/checksums/md5-checksums.bin differ diff --git a/.gradle/9.6.1/checksums/sha1-checksums.bin b/.gradle/9.6.1/checksums/sha1-checksums.bin new file mode 100644 index 0000000..116b869 Binary files /dev/null and b/.gradle/9.6.1/checksums/sha1-checksums.bin differ diff --git a/.gradle/9.6.1/executionHistory/executionHistory.bin b/.gradle/9.6.1/executionHistory/executionHistory.bin new file mode 100644 index 0000000..0afe79f Binary files /dev/null and b/.gradle/9.6.1/executionHistory/executionHistory.bin differ diff --git a/.gradle/9.6.1/executionHistory/executionHistory.lock b/.gradle/9.6.1/executionHistory/executionHistory.lock new file mode 100644 index 0000000..f6a552c Binary files /dev/null and b/.gradle/9.6.1/executionHistory/executionHistory.lock differ diff --git a/.gradle/9.6.1/fileChanges/last-build.bin b/.gradle/9.6.1/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/9.6.1/fileChanges/last-build.bin differ diff --git a/.gradle/9.6.1/fileHashes/fileHashes.bin b/.gradle/9.6.1/fileHashes/fileHashes.bin new file mode 100644 index 0000000..6dccf40 Binary files /dev/null and b/.gradle/9.6.1/fileHashes/fileHashes.bin differ diff --git a/.gradle/9.6.1/fileHashes/fileHashes.lock b/.gradle/9.6.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000..4aeb2ee Binary files /dev/null and b/.gradle/9.6.1/fileHashes/fileHashes.lock differ diff --git a/.gradle/9.6.1/fileHashes/resourceHashesCache.bin b/.gradle/9.6.1/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000..30f693c Binary files /dev/null and b/.gradle/9.6.1/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/9.6.1/gc.properties b/.gradle/9.6.1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..8ff9337 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..1a9af6f --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Sun Aug 02 18:47:18 MSK 2026 +gradle.version=9.6.1 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000..addffae Binary files /dev/null and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/.globals.work.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/.globals.work.bin new file mode 100644 index 0000000..158d01b --- /dev/null +++ b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/.globals.work.bin @@ -0,0 +1,2 @@ +4f(ے^ +iN \ No newline at end of file diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/.strings.work.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/.strings.work.bin new file mode 100644 index 0000000..11692d8 Binary files /dev/null and b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/.strings.work.bin differ diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/_.work.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/_.work.bin new file mode 100644 index 0000000..70c7c2d Binary files /dev/null and b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/_.work.bin differ diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/buildfingerprint.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/buildfingerprint.bin new file mode 100644 index 0000000..15568ed Binary files /dev/null and b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/buildfingerprint.bin differ diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/classloaderscopes.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/classloaderscopes.bin new file mode 100644 index 0000000..ae52637 Binary files /dev/null and b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/classloaderscopes.bin differ diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/entry.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/entry.bin new file mode 100644 index 0000000..2bb7b3d Binary files /dev/null and b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/entry.bin differ diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/projectfingerprint.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/projectfingerprint.bin new file mode 100644 index 0000000..86c0ec3 --- /dev/null +++ b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/projectfingerprint.bin @@ -0,0 +1,3 @@ +Bt 8[DQm +?&c$6)>' i]s43kBJLmOgiCLs-]vl Snڕ'uLgƁ%Е Ul$&OCyVg5 W2#r +CX Zċ"J@d_yq;f\wtr@!+_.NmK\B˹S W_fxyر KNjx5?Ik ŴJ3}ro׮R`L~ D*IOUY@27)oZO}AwޛVNOh+?NV:vn*' xbDS9#Xga&d)D ]ΜŻ7+Sry#n>8_*ik?@2P+׮(AGv +KPu[]OG\*gtVO%FG;|5gM7@K{{5zj{h"FKN+~ \ No newline at end of file diff --git a/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/work.bin b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/work.bin new file mode 100644 index 0000000..e57b73c Binary files /dev/null and b/.gradle/configuration-cache/089471ba-5ea8-4b4c-a4b1-d3192eb2329e/work.bin differ diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/.globals.work.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/.globals.work.bin new file mode 100644 index 0000000..8146df2 --- /dev/null +++ b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/.globals.work.bin @@ -0,0 +1 @@ + )rޠq²T \ No newline at end of file diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/.strings.work.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/.strings.work.bin new file mode 100644 index 0000000..5a4446b Binary files /dev/null and b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/.strings.work.bin differ diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/_.work.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/_.work.bin new file mode 100644 index 0000000..54aa13f Binary files /dev/null and b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/_.work.bin differ diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/buildfingerprint.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/buildfingerprint.bin new file mode 100644 index 0000000..f0bf575 Binary files /dev/null and b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/buildfingerprint.bin differ diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/classloaderscopes.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/classloaderscopes.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/classloaderscopes.bin differ diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/entry.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/entry.bin new file mode 100644 index 0000000..3cf45cf Binary files /dev/null and b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/entry.bin differ diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/projectfingerprint.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/projectfingerprint.bin new file mode 100644 index 0000000..1b8d38f Binary files /dev/null and b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/projectfingerprint.bin differ diff --git a/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/work.bin b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/work.bin new file mode 100644 index 0000000..a4bbf26 Binary files /dev/null and b/.gradle/configuration-cache/11f7cf1b-c522-4c0a-8d25-1983b5a7fa23/work.bin differ diff --git a/.gradle/configuration-cache/27n1092mio4qe3wkv681h5the/candidates.bin b/.gradle/configuration-cache/27n1092mio4qe3wkv681h5the/candidates.bin new file mode 100644 index 0000000..bd5b0f3 --- /dev/null +++ b/.gradle/configuration-cache/27n1092mio4qe3wkv681h5the/candidates.bin @@ -0,0 +1 @@ +089471ba-5ea8-4b4c-a4b1-d3192eb2329 \ No newline at end of file diff --git a/.gradle/configuration-cache/2qdf9wskj5iqajk2xjkqyg0pn/candidates.bin b/.gradle/configuration-cache/2qdf9wskj5iqajk2xjkqyg0pn/candidates.bin new file mode 100644 index 0000000..ce0a17f --- /dev/null +++ b/.gradle/configuration-cache/2qdf9wskj5iqajk2xjkqyg0pn/candidates.bin @@ -0,0 +1 @@ +11f7cf1b-c522-4c0a-8d25-1983b5a7fa2 \ No newline at end of file diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/.globals.work.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/.globals.work.bin new file mode 100644 index 0000000..27d7f3e --- /dev/null +++ b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/.globals.work.bin @@ -0,0 +1 @@ +>*m QA7>~ \ No newline at end of file diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/.strings.work.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/.strings.work.bin new file mode 100644 index 0000000..384a4be Binary files /dev/null and b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/.strings.work.bin differ diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/_.work.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/_.work.bin new file mode 100644 index 0000000..7196438 Binary files /dev/null and b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/_.work.bin differ diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/buildfingerprint.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/buildfingerprint.bin new file mode 100644 index 0000000..086e4c3 Binary files /dev/null and b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/buildfingerprint.bin differ diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/classloaderscopes.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/classloaderscopes.bin new file mode 100644 index 0000000..ee25442 Binary files /dev/null and b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/classloaderscopes.bin differ diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/entry.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/entry.bin new file mode 100644 index 0000000..c7cce09 Binary files /dev/null and b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/entry.bin differ diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/projectfingerprint.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/projectfingerprint.bin new file mode 100644 index 0000000..d4e5761 --- /dev/null +++ b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/projectfingerprint.bin @@ -0,0 +1,4 @@ +f tV +T9o +)Wys+I7V6r}<fiK;>ڞWK=S;*Gqm2AU_m~uIؙE+4ՆP5W>;CF*J ,JPq(+Ȉ iq.xj~ '†P@XDx 9GhgAyTVG;Uk70m= ^d>Gs>MLcST+u e,._.qU%4d&OiMlzcBwyIn͊dՙ:H0 +Ϛu$ +"ͰO80޽LKGᡅJSfh'kIW7tg \ No newline at end of file diff --git a/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/work.bin b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/work.bin new file mode 100644 index 0000000..b96d38e Binary files /dev/null and b/.gradle/configuration-cache/373cc2b8-5105-465d-91b2-de2f18c3e924/work.bin differ diff --git a/.gradle/configuration-cache/5lz7xass8vt3nd0p8lgom6m2w/candidates.bin b/.gradle/configuration-cache/5lz7xass8vt3nd0p8lgom6m2w/candidates.bin new file mode 100644 index 0000000..de4a9ce --- /dev/null +++ b/.gradle/configuration-cache/5lz7xass8vt3nd0p8lgom6m2w/candidates.bin @@ -0,0 +1 @@ +7b965951-6264-4f28-95e8-341df850318 \ No newline at end of file diff --git a/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/.globals.work.bin b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/.globals.work.bin new file mode 100644 index 0000000..90ad245 --- /dev/null +++ b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/.globals.work.bin @@ -0,0 +1 @@ +ðb9fIs3%v}D4 \ No newline at end of file diff --git a/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/.strings.work.bin b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/.strings.work.bin new file mode 100644 index 0000000..fa45159 Binary files /dev/null and b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/.strings.work.bin differ diff --git a/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/_.work.bin b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/_.work.bin new file mode 100644 index 0000000..b369708 Binary files /dev/null and b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/_.work.bin differ diff --git a/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/buildfingerprint.bin b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/buildfingerprint.bin new file mode 100644 index 0000000..930a611 Binary files /dev/null and b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/buildfingerprint.bin differ diff --git a/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/classloaderscopes.bin b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/classloaderscopes.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/classloaderscopes.bin differ diff --git a/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/entry.bin b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/entry.bin new file mode 100644 index 0000000..e95d747 Binary files /dev/null and b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/entry.bin differ diff --git a/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/projectfingerprint.bin b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/projectfingerprint.bin new file mode 100644 index 0000000..bd0f523 --- /dev/null +++ b/.gradle/configuration-cache/6213a67a-f31f-434b-8eb9-5dcf7e280c97/projectfingerprint.bin @@ -0,0 +1,3 @@ +yR;?&-)̓/y%K\Hs-zZT'kݧ1OMsb  8×jOĩs^fSw`+6b /08|}ꉂu2@q +R"{X6=^^p~ptk.!_7]8Ig7nAkGWgP T6RF:{6"|G5*-%޴$o_$X8`.B +և/Ȃ/lWÙې@'ߐC_EɨQ.\B@AN-xdD7 &?g&< NJG+0pG[HmKfRru 2G~)ӣ'燎>'!ARҒL*` + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/build/reports/configuration-cache/8j33gttysi79ilkcujngqbuh9/dplfaitbc5codclcbpo64nxdk/configuration-cache-report.html b/build/reports/configuration-cache/8j33gttysi79ilkcujngqbuh9/dplfaitbc5codclcbpo64nxdk/configuration-cache-report.html new file mode 100644 index 0000000..f41fd4c --- /dev/null +++ b/build/reports/configuration-cache/8j33gttysi79ilkcujngqbuh9/dplfaitbc5codclcbpo64nxdk/configuration-cache-report.html @@ -0,0 +1,666 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/build/reports/tests/test/css/base-style.css b/build/reports/tests/test/css/base-style.css new file mode 100644 index 0000000..d25afaf --- /dev/null +++ b/build/reports/tests/test/css/base-style.css @@ -0,0 +1,197 @@ + +body { + margin: 0; + padding: 0; + font-family: sans-serif; + font-size: 12pt; +} + +body, a, a:visited { + color: #303030; +} + +#content { + padding: 30px 50px; +} + +#content h1 { + font-size: 160%; + margin-bottom: 10px; +} + +#footer { + margin-top: 100px; + font-size: 80%; + white-space: nowrap; +} + +#footer, #footer a { + color: #a0a0a0; +} + +#line-wrapping-toggle { + vertical-align: middle; +} + +#label-for-line-wrapping-toggle { + vertical-align: middle; +} + +ul { + margin-left: 0; +} + +h1, h2, h3 { + white-space: nowrap; +} + +h2 { + font-size: 120%; +} + +.tab-container .tab-container { + margin-left: 8px; +} + +ul.tabLinks { + padding: 0; + margin-bottom: 0; + overflow: auto; + min-width: 800px; + width: auto; + border-bottom: solid 1px #aaa; +} + +ul.tabLinks li { + float: left; + height: 100%; + list-style: none; + padding: 5px 10px; + border-radius: 7px 7px 0 0; + border: solid 1px transparent; + border-bottom: none; + margin-right: 6px; + background-color: #f0f0f0; + cursor: pointer; +} + +ul.tabLinks li.deselected > a { + color: #6d6d6d; +} + +ul.tabLinks li:hover { + background-color: #fafafa; +} + +ul.tabLinks li.selected { + background-color: #c5f0f5; + border-color: #aaa; +} + +ul.tabLinks a { + font-size: 120%; + display: block; + outline: none; + text-decoration: none; + margin: 0; + padding: 0; +} + +ul.tabLinks li h2 { + margin: 0; + padding: 0; +} + +div.tab { +} + +div.selected { + display: block; +} + +div.deselected { + display: none; +} + +div.tab table { + min-width: 350px; + width: auto; + border-collapse: collapse; +} + +div.tab th, div.tab table { + border-bottom: solid 1px #d0d0d0; +} + +div.tab th { + text-align: left; + white-space: nowrap; + padding-left: 6em; +} + +div.tab th:first-child { + padding-left: 0; +} + +div.tab td { + white-space: nowrap; + padding-left: 6em; + padding-top: 5px; + padding-bottom: 5px; +} + +div.tab td:first-child { + padding-left: 0; +} + +div.tab td.numeric, div.tab th.numeric { + text-align: right; +} + +span.code { + display: block; + margin-top: 0; + margin-bottom: 1em; + position: relative; +} + +span.code pre { + font-size: 11pt; + padding: 10px; + margin: 0; + background-color: #f7f7f7; + border: solid 1px #d0d0d0; + min-width: 700px; + width: auto; + overflow: auto; +} + +span.wrapped pre { + word-wrap: break-word; + white-space: pre-wrap; + word-break: break-all; +} + +label.hidden { + display: none; +} + +th.sortable-header { + cursor: pointer; + user-select: none; +} + +th.sortable-header::after { + content: " \2195"; + color: #b0b0b0; +} + +th.sortable-header[data-sort-dir="asc"]::after { + content: " \25B2"; + color: inherit; +} + +th.sortable-header[data-sort-dir="desc"]::after { + content: " \25BC"; + color: inherit; +} diff --git a/build/reports/tests/test/css/style.css b/build/reports/tests/test/css/style.css new file mode 100644 index 0000000..0c644cb --- /dev/null +++ b/build/reports/tests/test/css/style.css @@ -0,0 +1,150 @@ +.summary { + margin-top: 30px; + margin-bottom: 40px; +} + +.summary table { + border-collapse: collapse; +} + +.summary td { + vertical-align: top; +} + +div.tab table.test-results td { + padding-left: 1em; +} + +div.tab table.test-results th { + padding-top: 0.5em; + padding-left: 1em; +} + +.breadcrumbs, .breadcrumbs a { + color: #606060; +} + +.infoBox { + width: 110px; + padding-top: 15px; + padding-bottom: 15px; + text-align: center; +} + +.infoBox p { + margin: 0; +} + +.counter, .percent { + font-size: 120%; + font-weight: bold; + margin-bottom: 8px; +} + +.duration { + width: 125px; +} + +.successRate, .summaryGroup { + border: solid 2px #d0d0d0; + -moz-border-radius: 10px; + border-radius: 10px; +} + +.successRate { + width: 140px; + margin-left: 35px; +} + +.successRate .percent { + font-size: 180%; +} + +.success, .success a { + color: #008000; +} + +div.success, .successRate.success { + background-color: #bbd9bb; + border-color: #008000; +} + +.failures, .failures a { + color: #b60808; +} + +.skipped, .skipped a { + color: #c09853; +} + +div.failures, .successRate.failures { + background-color: #ecdada; + border-color: #b60808; +} + +ul.linkList { + padding-left: 0; +} + +ul.linkList li { + list-style: none; + margin-bottom: 5px; +} + +div.metadata td:first-child { + padding-left: 5px; +} + +div.metadata td { + padding-left: 5px; +} + +.metadata tr.odd { + background-color: #f7f7f7; + border: solid 1px #d0d0d0; +} + +.metadata tr.even { + border: solid 1px #d0d0d0; +} + +.metadata th, .metadata td { + padding: 5px; + text-align: left; +} + +.metadata a { + color: blue; +} + +.metadata .unrenderable { + color: darkred; +} + +.clipboard-copy-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 8px; + font-size: 0.9em; + cursor: pointer; +} + +.successGroup::before { + content: "\23FA"; + margin-right: 8px; + color: #008000; + display: inline-block; +} +.failureGroup::before { + content: "\2297"; + margin-right: 8px; + color: #b60808; + display: inline-block; +} +.skippedGroup::before { + content: "\2296"; + margin-right: 8px; + color: #c09853; + display: inline-block; +} diff --git a/build/reports/tests/test/index.html b/build/reports/tests/test/index.html new file mode 100644 index 0000000..5e227a7 --- /dev/null +++ b/build/reports/tests/test/index.html @@ -0,0 +1,184 @@ + + + + + +Test results - All Results + + + + + +
+

All Results

+
+ +
+

Gradle Test Run :test

+

Gradle Test Run :test

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
21
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
2.273s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChildNameTestsFailuresSkippedDurationSuccess rate
+ActorReasonParserTest +org.kilka.moderationcommands.common.ActorReasonParserTest4000.134s100%
+BlockedCommandMatcherTest +org.kilka.moderationcommands.common.BlockedCommandMatcherTest2000.013s100%
+DurationParserTest +org.kilka.moderationcommands.common.DurationParserTest3000.012s100%
+DefaultConfigTest +org.kilka.moderationcommands.config.DefaultConfigTest3000.302s100%
+PluginDescriptorTest +org.kilka.moderationcommands.config.PluginDescriptorTest1000.017s100%
+FlectoneChatMuteIntegrationTest +org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest3000.043s100%
+YamlMuteRepositoryTest +org.kilka.moderationcommands.mute.YamlMuteRepositoryTest5000.092s100%
+
+
+
+
+
+ +
+ + diff --git a/build/reports/tests/test/js/report.js b/build/reports/tests/test/js/report.js new file mode 100644 index 0000000..a3f7193 --- /dev/null +++ b/build/reports/tests/test/js/report.js @@ -0,0 +1,285 @@ +(function (window, document) { + "use strict"; + + function changeElementClass(element, classValue) { + if (element.getAttribute("className")) { + element.setAttribute("className", classValue); + } else { + element.setAttribute("class", classValue); + } + } + + function getClassAttribute(element) { + if (element.getAttribute("className")) { + return element.getAttribute("className"); + } else { + return element.getAttribute("class"); + } + } + + function addClass(element, classValue) { + changeElementClass(element, getClassAttribute(element) + " " + classValue); + } + + function removeClass(element, classValue) { + changeElementClass(element, getClassAttribute(element).replace(classValue, "")); + } + + function getCheckBox() { + return document.getElementById("line-wrapping-toggle"); + } + + function getLabelForCheckBox() { + return document.getElementById("label-for-line-wrapping-toggle"); + } + + function findCodeBlocks() { + const codeBlocks = []; + const tabContainers = getTabContainers(); + for (let i = 0; i < tabContainers.length; i++) { + const spans = tabContainers[i].getElementsByTagName("span"); + for (let i = 0; i < spans.length; ++i) { + if (spans[i].className.indexOf("code") >= 0) { + codeBlocks.push(spans[i]); + } + } + } + return codeBlocks; + } + + function forAllCodeBlocks(operation) { + const codeBlocks = findCodeBlocks(); + + for (let i = 0; i < codeBlocks.length; ++i) { + operation(codeBlocks[i], "wrapped"); + } + } + + function toggleLineWrapping() { + const checkBox = getCheckBox(); + + if (checkBox.checked) { + forAllCodeBlocks(addClass); + } else { + forAllCodeBlocks(removeClass); + } + } + + function initClipboardCopyButton() { + document.querySelectorAll(".clipboard-copy-btn").forEach((button) => { + const copyElementId = button.getAttribute("data-copy-element-id"); + const elementWithCodeToSelect = document.getElementById(copyElementId); + + button.addEventListener("click", () => { + const text = elementWithCodeToSelect.innerText.trim(); + navigator.clipboard + .writeText(text) + .then(() => { + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = "Copy"; + }, 1500); + }) + .catch((err) => { + alert("Failed to copy to the clipboard: '" + err.message + "'. Check JavaScript console for more details.") + console.warn("Failed to copy to the clipboard", err); + }); + }); + }); + } + + function initControls() { + if (findCodeBlocks().length > 0) { + const checkBox = getCheckBox(); + const label = getLabelForCheckBox(); + + checkBox.onclick = toggleLineWrapping; + checkBox.checked = false; + + removeClass(label, "hidden"); + } + + initClipboardCopyButton() + } + + class TabManager { + baseId; + tabs; + titles; + headers; + + constructor(baseId, tabs, titles, headers) { + this.baseId = baseId; + this.tabs = tabs; + this.titles = titles; + this.headers = headers; + this.init(); + } + + init() { + for (let i = 0; i < this.headers.length; i++) { + const header = this.headers[i]; + header.onclick = () => { + this.select(i); + return false; + }; + } + } + + select(i) { + this.deselectAll(); + + changeElementClass(this.tabs[i], "tab selected"); + changeElementClass(this.headers[i], "selected"); + } + + deselectAll() { + for (let i = 0; i < this.tabs.length; i++) { + changeElementClass(this.tabs[i], "tab deselected"); + changeElementClass(this.headers[i], "deselected"); + } + } + } + + function getTabContainers() { + const tabContainers = Array.from(document.getElementsByClassName("tab-container")); + + // Used by existing TabbedPageRenderer users, which have not adjusted to use TabsRenderer yet. + const legacyContainer = document.getElementById("tabs"); + if (legacyContainer) { + tabContainers.push(legacyContainer); + } + + return tabContainers; + } + + function initTabs() { + let tabGroups = 0; + + function createTab(num, container) { + const tabElems = findTabs(container); + const tabManager = new TabManager("tabs" + num, tabElems, findTitles(tabElems), findHeaders(container)); + tabManager.select(0); + } + + const tabContainers = getTabContainers(); + + for (let i = 0; i < tabContainers.length; i++) { + createTab(tabGroups, tabContainers[i]); + tabGroups++; + } + + return true; + } + + function findTabs(container) { + return findChildElements(container, "DIV", "tab"); + } + + function findHeaders(container) { + const owner = findChildElements(container, "UL", "tabLinks"); + return findChildElements(owner[0], "LI", null); + } + + function findTitles(tabs) { + const titles = []; + + for (let i = 0; i < tabs.length; i++) { + const tab = tabs[i]; + const header = findChildElements(tab, "H2", null)[0]; + + header.parentNode.removeChild(header); + + if (header.innerText) { + titles.push(header.innerText); + } else { + titles.push(header.textContent); + } + } + + return titles; + } + + function findChildElements(container, name, targetClass) { + const elements = []; + const children = container.childNodes; + + for (let i = 0; i < children.length; i++) { + const child = children.item(i); + + if (child.nodeType === 1 && child.nodeName === name) { + if (targetClass && child.className.indexOf(targetClass) < 0) { + continue; + } + + elements.push(child); + } + } + + return elements; + } + + function initTableSorting() { + document.querySelectorAll("table.sortable").forEach((table) => { + const thead = table.tHead; + if (!thead || thead.rows.length === 0) return; + const headerRow = thead.rows[0]; + const headers = Array.from(headerRow.cells); + const tbody = table.tBodies[0] || table; + const originalRows = Array.from(tbody.querySelectorAll(":scope > tr")); + + headers.forEach((th, colIndex) => { + if (th.classList.contains("no-sort")) return; + th.classList.add("sortable-header"); + th.addEventListener("click", () => { + const current = th.getAttribute("data-sort-dir"); + const firstDir = th.getAttribute("data-sort-default") === "desc" ? "desc" : "asc"; + const secondDir = firstDir === "desc" ? "asc" : "desc"; + // Cycle: none -> firstDir -> secondDir -> none + let next; + if (current === firstDir) next = secondDir; + else if (current === secondDir) next = "none"; + else next = firstDir; + + headers.forEach((h) => h.removeAttribute("data-sort-dir")); + + if (next === "none") { + originalRows.forEach((r) => tbody.appendChild(r)); + return; + } + th.setAttribute("data-sort-dir", next); + + const rows = Array.from(tbody.querySelectorAll(":scope > tr")); + const dir = next === "asc" ? 1 : -1; + rows.sort((a, b) => { + const av = getSortValue(a.cells[colIndex]); + const bv = getSortValue(b.cells[colIndex]); + if (av === bv) return 0; + // Numeric compare if both parse as numbers + const an = parseFloat(av), bn = parseFloat(bv); + if (!isNaN(an) && !isNaN(bn) && isFinite(an) && isFinite(bn) + && av.trim() !== "" && bv.trim() !== "") { + return (an - bn) * dir; + } + return av.localeCompare(bv) * dir; + }); + rows.forEach((r) => tbody.appendChild(r)); + }); + }); + }); + } + + function getSortValue(cell) { + if (!cell) return ""; + const v = cell.getAttribute("data-sort-value"); + return v !== null ? v : cell.textContent; + } + + // Entry point. + + window.onload = function() { + initTabs(); + initControls(); + initTableSorting(); + }; +} (window, window.document)); diff --git a/build/reports/tests/test/org.kilka.moderationcommands.common.ActorReasonParserTest/index.html b/build/reports/tests/test/org.kilka.moderationcommands.common.ActorReasonParserTest/index.html new file mode 100644 index 0000000..f515d8c --- /dev/null +++ b/build/reports/tests/test/org.kilka.moderationcommands.common.ActorReasonParserTest/index.html @@ -0,0 +1,146 @@ + + + + + +Test results - org.kilka.moderationcommands.common.ActorReasonParserTest + + + + + +
+ +
+ +
+

Gradle Test Run :test

+

ActorReasonParserTest

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
4
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
0.134s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChildTestsFailuresSkippedDurationSuccess rate
extractsConsoleActorAndRemovesItFromReason()1000.005s100%
rejectsActorFromPlayerWithoutChangingTheAuthor()1000.065s100%
rejectsDuplicateMalformedAndMissingValues()1000.006s100%
usesConsoleWhenActorIsAbsent()1000.013s100%
+
+
+
+
+
+ +
+ + diff --git a/build/reports/tests/test/org.kilka.moderationcommands.common.BlockedCommandMatcherTest/index.html b/build/reports/tests/test/org.kilka.moderationcommands.common.BlockedCommandMatcherTest/index.html new file mode 100644 index 0000000..d63dbee --- /dev/null +++ b/build/reports/tests/test/org.kilka.moderationcommands.common.BlockedCommandMatcherTest/index.html @@ -0,0 +1,128 @@ + + + + + +Test results - org.kilka.moderationcommands.common.BlockedCommandMatcherTest + + + + + +
+ +
+ +
+

Gradle Test Run :test

+

BlockedCommandMatcherTest

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
2
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
0.013s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChildTestsFailuresSkippedDurationSuccess rate
blocksPlainCaseInsensitiveAndNamespacedCommands()1000.003s100%
doesNotMatchArgumentsOrPrefixes()1000.006s100%
+
+
+
+
+
+ +
+ + diff --git a/build/reports/tests/test/org.kilka.moderationcommands.common.DurationParserTest/index.html b/build/reports/tests/test/org.kilka.moderationcommands.common.DurationParserTest/index.html new file mode 100644 index 0000000..3cf5e86 --- /dev/null +++ b/build/reports/tests/test/org.kilka.moderationcommands.common.DurationParserTest/index.html @@ -0,0 +1,137 @@ + + + + + +Test results - org.kilka.moderationcommands.common.DurationParserTest + + + + + +
+ +
+ +
+

Gradle Test Run :test

+

DurationParserTest

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
3
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
0.012s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChildTestsFailuresSkippedDurationSuccess rate
parsesRequiredAndGeneralizedDurations()1000.002s100%
rejectsExpirationOverflow()1000.002s100%
rejectsInvalidValuesAndArithmeticOverflow()1000.003s100%
+
+
+
+
+
+ +
+ + diff --git a/build/reports/tests/test/org.kilka.moderationcommands.config.DefaultConfigTest/index.html b/build/reports/tests/test/org.kilka.moderationcommands.config.DefaultConfigTest/index.html new file mode 100644 index 0000000..130438b --- /dev/null +++ b/build/reports/tests/test/org.kilka.moderationcommands.config.DefaultConfigTest/index.html @@ -0,0 +1,137 @@ + + + + + +Test results - org.kilka.moderationcommands.config.DefaultConfigTest + + + + + +
+ +
+ +
+

Gradle Test Run :test

+

DefaultConfigTest

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
3
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
0.302s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChildTestsFailuresSkippedDurationSuccess rate
defaultConfigContainsRequiredCommandsAndValidMessages()1000.234s100%
missingIntegrationSettingsUseBackwardsCompatibleDefaults()1000.061s100%
rejectsInvalidVoicePermission()1000.002s100%
+
+
+
+
+
+ +
+ + diff --git a/build/reports/tests/test/org.kilka.moderationcommands.config.PluginDescriptorTest/index.html b/build/reports/tests/test/org.kilka.moderationcommands.config.PluginDescriptorTest/index.html new file mode 100644 index 0000000..b65b1a2 --- /dev/null +++ b/build/reports/tests/test/org.kilka.moderationcommands.config.PluginDescriptorTest/index.html @@ -0,0 +1,119 @@ + + + + + +Test results - org.kilka.moderationcommands.config.PluginDescriptorTest + + + + + +
+ +
+ +
+

Gradle Test Run :test

+

PluginDescriptorTest

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
1
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
0.017s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
ChildTestsFailuresSkippedDurationSuccess rate
commandsExposeTheSamePermissionsCheckedByExecutors()1000.015s100%
+
+
+
+
+
+ +
+ + diff --git a/build/reports/tests/test/org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest/index.html b/build/reports/tests/test/org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest/index.html new file mode 100644 index 0000000..726ab3e --- /dev/null +++ b/build/reports/tests/test/org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest/index.html @@ -0,0 +1,137 @@ + + + + + +Test results - org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest + + + + + +
+ +
+ +
+

Gradle Test Run :test

+

FlectoneChatMuteIntegrationTest

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
3
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
0.043s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChildTestsFailuresSkippedDurationSuccess rate
cancelsFlectoneChatAndNotifiesMutedSender()1000.003s100%
disabledPermanentListenerBecomesNoOp()1000.003s100%
leavesOtherModulesAndUnmutedPlayersUntouched()1000.027s100%
+
+
+
+
+
+ +
+ + diff --git a/build/reports/tests/test/org.kilka.moderationcommands.mute.YamlMuteRepositoryTest/index.html b/build/reports/tests/test/org.kilka.moderationcommands.mute.YamlMuteRepositoryTest/index.html new file mode 100644 index 0000000..74d78a9 --- /dev/null +++ b/build/reports/tests/test/org.kilka.moderationcommands.mute.YamlMuteRepositoryTest/index.html @@ -0,0 +1,155 @@ + + + + + +Test results - org.kilka.moderationcommands.mute.YamlMuteRepositoryTest + + + + + +
+ +
+ +
+

Gradle Test Run :test

+

YamlMuteRepositoryTest

+
+ +
+

summary

+
+
+ + + + + +
+
+ + + + + + + +
+
+
5
+

tests

+
+
+
+
0
+

failures

+
+
+
+
0
+

skipped

+
+
+
+
0.092s
+

duration

+
+
+
+
+
+
100%
+

successful

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChildTestsFailuresSkippedDurationSuccess rate
expirationIsImmediateAndCleanupIsPersistent()1000.046s100%
failedAtomicWriteKeepsThePublishedSnapshot()1000.010s100%
persistsTemporaryAndPermanentMutesAcrossReloads()1000.014s100%
rejectsCorruptedStorageWithoutOverwritingIt()1000.007s100%
repeatMuteReplacesTheUuidEntry()1000.008s100%
+
+
+
+
+
+ +
+ + diff --git a/build/resources/main/config.yml b/build/resources/main/config.yml new file mode 100644 index 0000000..e702523 --- /dev/null +++ b/build/resources/main/config.yml @@ -0,0 +1,46 @@ +mute: + integrations: + flectone: + enabled: true + voice: + enabled: true + permission: voicechat.speak + blocked-commands: + - msg + - tell + - w + - whisper + - reply + - r + - me + +messages: + prefix: "[Moderation] " + no-permission: "У вас нет права ." + unsupported-sender: "Эту команду может выполнить только игрок или обычная консоль." + usage-mute: "Использование: /mute [actor:] " + usage-unmute: "Использование: /unmute [actor:] [reason...]" + usage-ban: "Использование: /ban [actor:] " + usage-unban: "Использование: /unban [actor:] [reason...]" + usage-kick: "Использование: /kick [actor:] " + invalid-duration: "Неизвестный или недопустимый срок: ." + invalid-actor: "Аргумент actor: указан неверно." + player-actor-forbidden: "Игрок не может передавать actor: вручную." + missing-reason: "Необходимо указать причину." + target-not-found: "Профиль не найден среди известных серверу игроков. Можно указать UUID." + target-offline: "Игрок уже вышел с сервера." + no-active-mute: "У игрока нет активного мута." + no-active-ban: "У профиля нет активного бана." + storage-error: "Не удалось надёжно сохранить изменение. Наказание не применено." + action-failed: "Не удалось применить действие. Подробности записаны в журнал сервера." + mute-success: "Мут игрока установлен на . Автор: ." + mute-target: "Вам запрещён чат. Причина: . Модератор: . Срок: ." + unmute-success: "Мут игрока снят. Автор: ." + unmute-target: "Ваш мут снят. Модератор: . Причина снятия: ." + ban-success: "Профиль заблокирован на . Автор: ." + ban-disconnect: "Вы заблокированы на сервере.\nПричина: \nМодератор: \nСрок: " + unban-success: "Бан профиля снят. Автор: ." + kick-success: "Игрок исключён с сервера. Автор: ." + kick-disconnect: "Вы исключены с сервера.\nПричина: \nМодератор: " + muted-attempt: "Вы не можете писать. Причина: . Осталось: ." + reason-not-specified: "не указана" diff --git a/build/resources/main/plugin.yml b/build/resources/main/plugin.yml new file mode 100644 index 0000000..7e1b706 --- /dev/null +++ b/build/resources/main/plugin.yml @@ -0,0 +1,54 @@ +name: ModerationCommands +description: Moderation commands for Paper servers +prefix: ModerationCommands +version: '1.0' + +main: org.kilka.moderationcommands.ModerationCommandsPlugin +api-version: '26.1.2' +load: POSTWORLD +softdepend: [ FlectonePulse, LuckPerms ] + +authors: [ Kilka_v_HJIebe ] + +commands: + mute: + description: Mute a player + permission: moderation.command.mute + unmute: + description: Remove an active mute + permission: moderation.command.unmute + ban: + description: Ban a player profile + permission: moderation.command.ban + unban: + description: Remove an active profile ban + permission: moderation.command.unban + kick: + description: Kick an online player + permission: moderation.command.kick + +permissions: + moderation.command.*: + description: Allows all ModerationCommands commands + default: op + children: + moderation.command.mute: true + moderation.command.unmute: true + moderation.command.ban: true + moderation.command.unban: true + moderation.command.kick: true + moderation.command.mute: + description: Allows /mute + default: op + moderation.command.unmute: + description: Allows /unmute + default: op + moderation.command.ban: + description: Allows /ban + default: op + moderation.command.unban: + description: Allows /unban + default: op + moderation.command.kick: + description: Allows /kick + default: op diff --git a/build/test-results/test/TEST-org.kilka.moderationcommands.common.ActorReasonParserTest.xml b/build/test-results/test/TEST-org.kilka.moderationcommands.common.ActorReasonParserTest.xml new file mode 100644 index 0000000..ce44224 --- /dev/null +++ b/build/test-results/test/TEST-org.kilka.moderationcommands.common.ActorReasonParserTest.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/build/test-results/test/TEST-org.kilka.moderationcommands.common.BlockedCommandMatcherTest.xml b/build/test-results/test/TEST-org.kilka.moderationcommands.common.BlockedCommandMatcherTest.xml new file mode 100644 index 0000000..6199371 --- /dev/null +++ b/build/test-results/test/TEST-org.kilka.moderationcommands.common.BlockedCommandMatcherTest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/test-results/test/TEST-org.kilka.moderationcommands.common.DurationParserTest.xml b/build/test-results/test/TEST-org.kilka.moderationcommands.common.DurationParserTest.xml new file mode 100644 index 0000000..6964aa9 --- /dev/null +++ b/build/test-results/test/TEST-org.kilka.moderationcommands.common.DurationParserTest.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build/test-results/test/TEST-org.kilka.moderationcommands.config.DefaultConfigTest.xml b/build/test-results/test/TEST-org.kilka.moderationcommands.config.DefaultConfigTest.xml new file mode 100644 index 0000000..cbc90f0 --- /dev/null +++ b/build/test-results/test/TEST-org.kilka.moderationcommands.config.DefaultConfigTest.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build/test-results/test/TEST-org.kilka.moderationcommands.config.PluginDescriptorTest.xml b/build/test-results/test/TEST-org.kilka.moderationcommands.config.PluginDescriptorTest.xml new file mode 100644 index 0000000..99c0cd5 --- /dev/null +++ b/build/test-results/test/TEST-org.kilka.moderationcommands.config.PluginDescriptorTest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/build/test-results/test/TEST-org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest.xml b/build/test-results/test/TEST-org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest.xml new file mode 100644 index 0000000..fcdec03 --- /dev/null +++ b/build/test-results/test/TEST-org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegrationTest.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build/test-results/test/TEST-org.kilka.moderationcommands.mute.YamlMuteRepositoryTest.xml b/build/test-results/test/TEST-org.kilka.moderationcommands.mute.YamlMuteRepositoryTest.xml new file mode 100644 index 0000000..ac1674f --- /dev/null +++ b/build/test-results/test/TEST-org.kilka.moderationcommands.mute.YamlMuteRepositoryTest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/build/test-results/test/binary/output-events.bin b/build/test-results/test/binary/output-events.bin new file mode 100644 index 0000000..e69de29 diff --git a/build/test-results/test/binary/results-generic.bin b/build/test-results/test/binary/results-generic.bin new file mode 100644 index 0000000..15d5c8d Binary files /dev/null and b/build/test-results/test/binary/results-generic.bin differ diff --git a/build/tmp/compileJava/previous-compilation-data.bin b/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 0000000..31ffaff Binary files /dev/null and b/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/build/tmp/compileTestJava/previous-compilation-data.bin b/build/tmp/compileTestJava/previous-compilation-data.bin new file mode 100644 index 0000000..ac45836 Binary files /dev/null and b/build/tmp/compileTestJava/previous-compilation-data.bin differ diff --git a/build/tmp/jar/MANIFEST.MF b/build/tmp/jar/MANIFEST.MF new file mode 100644 index 0000000..58630c0 --- /dev/null +++ b/build/tmp/jar/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..020d283 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +group=org.kilka +version=1.1 +description=Moderation commands for Paper servers +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..0262dcb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..0497625 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "ModerationCommands" diff --git a/src/main/java/org/kilka/moderationcommands/ModerationCommandsPlugin.java b/src/main/java/org/kilka/moderationcommands/ModerationCommandsPlugin.java new file mode 100644 index 0000000..cb3066c --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/ModerationCommandsPlugin.java @@ -0,0 +1,178 @@ +package org.kilka.moderationcommands; + +import io.papermc.paper.ban.BanListType; +import java.io.IOException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.bukkit.ban.ProfileBanList; +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; +import org.kilka.moderationcommands.ban.BanService; +import org.kilka.moderationcommands.command.BanCommand; +import org.kilka.moderationcommands.command.CommandOwnership; +import org.kilka.moderationcommands.command.KickCommand; +import org.kilka.moderationcommands.command.ModerationCommand; +import org.kilka.moderationcommands.command.MuteCommand; +import org.kilka.moderationcommands.command.UnbanCommand; +import org.kilka.moderationcommands.command.UnmuteCommand; +import org.kilka.moderationcommands.common.ActorReasonParser; +import org.kilka.moderationcommands.common.BlockedCommandMatcher; +import org.kilka.moderationcommands.common.DurationFormatter; +import org.kilka.moderationcommands.common.DurationParser; +import org.kilka.moderationcommands.common.KnownPlayerResolver; +import org.kilka.moderationcommands.config.MessageService; +import org.kilka.moderationcommands.config.ModerationSettings; +import org.kilka.moderationcommands.kick.KickService; +import org.kilka.moderationcommands.integration.flectone.FlectoneChatMuteIntegration; +import org.kilka.moderationcommands.integration.luckperms.LuckPermsVoiceMuteIntegration; +import org.kilka.moderationcommands.mute.MuteEnforcementListener; +import org.kilka.moderationcommands.mute.MuteLifecycleCoordinator; +import org.kilka.moderationcommands.mute.MuteLifecycleObserver; +import org.kilka.moderationcommands.mute.MuteService; +import org.kilka.moderationcommands.mute.MutedAttemptNotifier; +import org.kilka.moderationcommands.mute.YamlMuteRepository; + +public final class ModerationCommandsPlugin extends JavaPlugin { + private BukkitTask cleanupTask; + private final List activeIntegrations = new ArrayList<>(); + + @Override + public void onEnable() { + saveDefaultConfig(); + + try { + Clock clock = Clock.systemUTC(); + ModerationSettings settings = ModerationSettings.load(getConfig()); + MessageService messages = new MessageService(getConfig()); + KnownPlayerResolver playerResolver = new KnownPlayerResolver(getServer()); + DurationParser durationParser = new DurationParser(); + DurationFormatter durationFormatter = new DurationFormatter(); + ActorReasonParser actorReasonParser = new ActorReasonParser(); + + YamlMuteRepository muteRepository = new YamlMuteRepository( + getDataFolder().toPath().resolve("mutes.yml") + ); + MuteService muteService = new MuteService(muteRepository, playerResolver, clock); + MutedAttemptNotifier mutedAttemptNotifier = new MutedAttemptNotifier( + this, + muteService, + durationFormatter, + messages + ); + + List muteObservers = new ArrayList<>(); + if (settings.voiceEnabled()) { + if (getServer().getPluginManager().getPlugin("LuckPerms") == null) { + getLogger().warning("LuckPerms is not installed; Simple Voice Chat mute is disabled"); + } else { + LuckPermsVoiceMuteIntegration.start(this, muteService, settings.voicePermission()) + .ifPresent(integration -> { + muteObservers.add(integration); + activeIntegrations.add(integration); + }); + } + } + MuteLifecycleCoordinator muteLifecycle = new MuteLifecycleCoordinator( + muteService, + muteObservers, + getLogger() + ); + + ProfileBanList profileBanList = Bukkit.getBanList(BanListType.PROFILE); + BanService banService = new BanService(profileBanList, playerResolver, clock); + + register("mute", new MuteCommand( + this, + messages, + actorReasonParser, + durationParser, + durationFormatter, + playerResolver, + muteLifecycle, + clock + )); + register("unmute", new UnmuteCommand( + this, + messages, + actorReasonParser, + muteService, + muteLifecycle + )); + register("ban", new BanCommand( + this, + messages, + actorReasonParser, + durationParser, + durationFormatter, + playerResolver, + banService, + clock + )); + register("unban", new UnbanCommand(this, messages, actorReasonParser, banService)); + register("kick", new KickCommand( + this, + messages, + actorReasonParser, + playerResolver, + new KickService() + )); + + CommandOwnership.claim(this, List.of("mute", "unmute", "ban", "unban", "kick")) + .ifPresent(activeIntegrations::add); + + getServer().getPluginManager().registerEvents(new MuteEnforcementListener( + muteService, + new BlockedCommandMatcher(settings.blockedCommands()), + mutedAttemptNotifier + ), this); + + if (settings.flectoneEnabled()) { + if (getServer().getPluginManager().getPlugin("FlectonePulse") == null) { + getLogger().warning("FlectonePulse is not installed; only Paper chat events will be enforced"); + } else { + FlectoneChatMuteIntegration.start(this, muteService, mutedAttemptNotifier) + .ifPresent(activeIntegrations::add); + } + } + + cleanupTask = getServer().getScheduler().runTaskTimer(this, () -> { + try { + muteLifecycle.purgeExpired(); + } catch (IOException exception) { + getLogger().log(Level.SEVERE, "Could not purge expired mutes", exception); + } + }, 20L * 60L, 20L * 60L); + } catch (IOException | InvalidConfigurationException | RuntimeException exception) { + getLogger().log(Level.SEVERE, "ModerationCommands could not be enabled safely", exception); + getServer().getPluginManager().disablePlugin(this); + } + } + + @Override + public void onDisable() { + if (cleanupTask != null) { + cleanupTask.cancel(); + cleanupTask = null; + } + for (int index = activeIntegrations.size() - 1; index >= 0; index--) { + try { + activeIntegrations.get(index).close(); + } catch (Exception exception) { + getLogger().log(Level.WARNING, "Could not cleanly stop an optional integration", exception); + } + } + activeIntegrations.clear(); + } + + private void register(String name, ModerationCommand executor) { + PluginCommand command = Objects.requireNonNull(getCommand(name), "Missing command in plugin.yml: " + name); + command.setExecutor(executor); + command.setTabCompleter(executor); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/ban/BanService.java b/src/main/java/org/kilka/moderationcommands/ban/BanService.java new file mode 100644 index 0000000..47fc9aa --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/ban/BanService.java @@ -0,0 +1,86 @@ +package org.kilka.moderationcommands.ban; + +import com.destroystokyo.paper.profile.PlayerProfile; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.BanEntry; +import org.bukkit.ban.ProfileBanList; +import org.kilka.moderationcommands.common.KnownPlayerResolver; +import org.kilka.moderationcommands.common.PlayerTarget; + +public final class BanService { + private final ProfileBanList banList; + private final KnownPlayerResolver playerResolver; + private final Clock clock; + + public BanService(ProfileBanList banList, KnownPlayerResolver playerResolver, Clock clock) { + this.banList = banList; + this.playerResolver = playerResolver; + this.clock = clock; + } + + public boolean ban(PlayerTarget target, String reason, Instant expiresAt, String actor) { + return banList.addBan(target.profile(), reason, expiresAt, actor) != null; + } + + public Optional> findActive(String input) { + UUID requestedUuid = KnownPlayerResolver.parseUuid(input); + Optional> direct = findAmongEntries(input, requestedUuid); + if (direct.isPresent()) { + return direct; + } + + return playerResolver.resolve(input) + .flatMap(target -> findAmongEntries(input, target.uniqueId())); + } + + public void remove(BanEntry entry) { + entry.remove(); + } + + public List activeProfileNames() { + List names = new ArrayList<>(); + Instant now = clock.instant(); + for (BanEntry entry : entries()) { + if (!isActive(entry, now)) { + continue; + } + String name = entry.getBanTarget().getName(); + names.add(name == null ? entry.getBanTarget().getId().toString() : name); + } + return List.copyOf(names); + } + + private Optional> findAmongEntries(String input, UUID requestedUuid) { + BanEntry match = null; + Instant now = clock.instant(); + for (BanEntry entry : entries()) { + if (!isActive(entry, now)) { + continue; + } + PlayerProfile profile = entry.getBanTarget(); + boolean matchesUuid = requestedUuid != null && requestedUuid.equals(profile.getId()); + boolean matchesName = profile.getName() != null && profile.getName().equalsIgnoreCase(input); + if (!matchesUuid && !matchesName) { + continue; + } + if (match != null && !match.getBanTarget().getId().equals(profile.getId())) { + return Optional.empty(); + } + match = entry; + } + return Optional.ofNullable(match); + } + + private boolean isActive(BanEntry entry, Instant now) { + return entry.getExpiration() == null || entry.getExpiration().toInstant().isAfter(now); + } + + private Iterable> entries() { + return banList.getEntries(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/BanCommand.java b/src/main/java/org/kilka/moderationcommands/command/BanCommand.java new file mode 100644 index 0000000..2ec3869 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/BanCommand.java @@ -0,0 +1,100 @@ +package org.kilka.moderationcommands.command; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import net.kyori.adventure.text.Component; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.kilka.moderationcommands.ban.BanService; +import org.kilka.moderationcommands.common.ActorReasonParser; +import org.kilka.moderationcommands.common.DurationFormatter; +import org.kilka.moderationcommands.common.DurationParser; +import org.kilka.moderationcommands.common.KnownPlayerResolver; +import org.kilka.moderationcommands.common.PlayerTarget; +import org.kilka.moderationcommands.common.PunishmentDuration; +import org.kilka.moderationcommands.common.ValidationException; +import org.kilka.moderationcommands.config.MessageService; + +public final class BanCommand extends ModerationCommand { + private final DurationParser durationParser; + private final DurationFormatter durationFormatter; + private final KnownPlayerResolver playerResolver; + private final BanService banService; + private final Clock clock; + + public BanCommand( + JavaPlugin plugin, + MessageService messages, + ActorReasonParser actorReasonParser, + DurationParser durationParser, + DurationFormatter durationFormatter, + KnownPlayerResolver playerResolver, + BanService banService, + Clock clock + ) { + super(plugin, messages, actorReasonParser, "moderation.command.ban"); + this.durationParser = durationParser; + this.durationFormatter = durationFormatter; + this.playerResolver = playerResolver; + this.banService = banService; + this.clock = clock; + } + + @Override + protected boolean execute(CommandSender sender, String[] args) throws ValidationException { + if (args.length < 3) { + throw new ValidationException("usage-ban"); + } + Instant issuedAt = clock.instant(); + PunishmentDuration duration = durationParser.parse(args[1], issuedAt) + .orElseThrow(() -> invalidDuration(args[1])); + Instant expiresAt = duration.expiresAt(issuedAt); + ActorReasonParser.ActorReason context = parseActorAndReason(sender, args, 2, true); + PlayerTarget target = playerResolver.resolve(args[0]).orElseThrow(() -> new ValidationException( + "target-not-found", + Map.of("target", Component.text(args[0])) + )); + + if (!banService.ban(target, context.reason(), expiresAt, context.actor())) { + throw new ValidationException("action-failed"); + } + + Map placeholders = Map.of( + "target", Component.text(target.displayName()), + "actor", Component.text(context.actor()), + "reason", Component.text(context.reason()), + "duration", Component.text(durationFormatter.format(duration)) + ); + Player onlineTarget = plugin.getServer().getPlayer(target.uniqueId()); + if (onlineTarget != null) { + onlineTarget.kick(messages.render("ban-disconnect", placeholders)); + } + messages.send(sender, "ban-success", placeholders); + return true; + } + + private ValidationException invalidDuration(String input) { + return new ValidationException("invalid-duration", Map.of("duration", Component.text(input))); + } + + @Override + public @NotNull List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args + ) { + if (args.length == 1) { + return CommandCompletions.onlinePlayers(plugin.getServer(), args[0]); + } + if (args.length == 2) { + return CommandCompletions.durations(args[1]); + } + return List.of(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/CommandCompletions.java b/src/main/java/org/kilka/moderationcommands/command/CommandCompletions.java new file mode 100644 index 0000000..100f866 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/CommandCompletions.java @@ -0,0 +1,29 @@ +package org.kilka.moderationcommands.command; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import org.bukkit.Server; + +final class CommandCompletions { + private static final List DURATIONS = List.of("1h", "1d", "7d", "30d", "permanent"); + + private CommandCompletions() { + } + + static List onlinePlayers(Server server, String prefix) { + return filter(server.getOnlinePlayers().stream().map(player -> player.getName()).toList(), prefix); + } + + static List durations(String prefix) { + return filter(DURATIONS, prefix); + } + + static List filter(Collection candidates, String prefix) { + String normalized = prefix.toLowerCase(Locale.ROOT); + return candidates.stream() + .filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(normalized)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/CommandOwnership.java b/src/main/java/org/kilka/moderationcommands/command/CommandOwnership.java new file mode 100644 index 0000000..8cde97a --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/CommandOwnership.java @@ -0,0 +1,101 @@ +package org.kilka.moderationcommands.command; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import org.bukkit.command.Command; +import org.bukkit.command.CommandMap; +import org.bukkit.command.PluginIdentifiableCommand; +import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +public final class CommandOwnership implements AutoCloseable { + private final JavaPlugin plugin; + private final CommandMap commandMap; + private final Map claimed = new HashMap<>(); + private final Map replaced = new HashMap<>(); + + private CommandOwnership(JavaPlugin plugin) { + this.plugin = plugin; + this.commandMap = plugin.getServer().getCommandMap(); + } + + public static Optional claim(JavaPlugin plugin, List commandNames) { + CommandOwnership ownership = new CommandOwnership(plugin); + try { + ownership.claimAll(commandNames); + return Optional.of(ownership); + } catch (RuntimeException exception) { + ownership.restore(); + plugin.getLogger().log( + Level.SEVERE, + "Could not claim moderation command labels; use /moderationcommands: as a fallback", + exception + ); + return Optional.empty(); + } + } + + private void claimAll(List commandNames) { + Map knownCommands = commandMap.getKnownCommands(); + for (String commandName : commandNames) { + String label = commandName.toLowerCase(Locale.ROOT); + PluginCommand ownCommand = plugin.getCommand(label); + if (ownCommand == null) { + throw new IllegalStateException("Missing command in plugin.yml: " + label); + } + + Command previous = knownCommands.put(label, ownCommand); + claimed.put(label, ownCommand); + if (previous != null && previous != ownCommand) { + replaced.put(label, previous); + plugin.getLogger().warning( + "Command /" + label + " was owned by " + describe(previous) + + "; the bare label is now routed to ModerationCommands" + ); + } + } + refreshOnlineCommandTrees(); + } + + private String describe(Command command) { + if (command instanceof PluginIdentifiableCommand identifiable) { + return identifiable.getPlugin().getName(); + } + return command.getClass().getName(); + } + + private void refreshOnlineCommandTrees() { + for (Player player : plugin.getServer().getOnlinePlayers()) { + player.updateCommands(); + } + } + + @Override + public void close() { + restore(); + } + + private void restore() { + Map knownCommands = commandMap.getKnownCommands(); + for (Map.Entry entry : claimed.entrySet()) { + String label = entry.getKey(); + if (knownCommands.get(label) != entry.getValue()) { + continue; + } + Command previous = replaced.get(label); + if (previous == null) { + knownCommands.remove(label); + } else { + knownCommands.put(label, previous); + } + } + claimed.clear(); + replaced.clear(); + refreshOnlineCommandTrees(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/KickCommand.java b/src/main/java/org/kilka/moderationcommands/command/KickCommand.java new file mode 100644 index 0000000..21cfa77 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/KickCommand.java @@ -0,0 +1,66 @@ +package org.kilka.moderationcommands.command; + +import java.util.List; +import java.util.Map; +import net.kyori.adventure.text.Component; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.kilka.moderationcommands.common.ActorReasonParser; +import org.kilka.moderationcommands.common.KnownPlayerResolver; +import org.kilka.moderationcommands.common.ValidationException; +import org.kilka.moderationcommands.config.MessageService; +import org.kilka.moderationcommands.kick.KickService; + +public final class KickCommand extends ModerationCommand { + private final KnownPlayerResolver playerResolver; + private final KickService kickService; + + public KickCommand( + JavaPlugin plugin, + MessageService messages, + ActorReasonParser actorReasonParser, + KnownPlayerResolver playerResolver, + KickService kickService + ) { + super(plugin, messages, actorReasonParser, "moderation.command.kick"); + this.playerResolver = playerResolver; + this.kickService = kickService; + } + + @Override + protected boolean execute(CommandSender sender, String[] args) throws ValidationException { + if (args.length < 2) { + throw new ValidationException("usage-kick"); + } + ActorReasonParser.ActorReason context = parseActorAndReason(sender, args, 1, true); + Player target = playerResolver.resolveOnline(args[0]).orElseThrow(() -> new ValidationException( + "target-offline", + Map.of("target", Component.text(args[0])) + )); + Map placeholders = Map.of( + "target", Component.text(target.getName()), + "actor", Component.text(context.actor()), + "reason", Component.text(context.reason()) + ); + if (!kickService.kick(target, messages.render("kick-disconnect", placeholders))) { + throw new ValidationException("target-offline", Map.of("target", Component.text(args[0]))); + } + messages.send(sender, "kick-success", placeholders); + return true; + } + + @Override + public @NotNull List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args + ) { + return args.length == 1 + ? CommandCompletions.onlinePlayers(plugin.getServer(), args[0]) + : List.of(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/ModerationCommand.java b/src/main/java/org/kilka/moderationcommands/command/ModerationCommand.java new file mode 100644 index 0000000..5c13314 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/ModerationCommand.java @@ -0,0 +1,98 @@ +package org.kilka.moderationcommands.command; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import net.kyori.adventure.text.Component; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.kilka.moderationcommands.common.ActorReasonParser; +import org.kilka.moderationcommands.common.ValidationException; +import org.kilka.moderationcommands.config.MessageService; + +public abstract class ModerationCommand implements CommandExecutor, TabCompleter { + protected final JavaPlugin plugin; + protected final MessageService messages; + protected final ActorReasonParser actorReasonParser; + private final String permission; + + protected ModerationCommand( + JavaPlugin plugin, + MessageService messages, + ActorReasonParser actorReasonParser, + String permission + ) { + this.plugin = plugin; + this.messages = messages; + this.actorReasonParser = actorReasonParser; + this.permission = permission; + } + + @Override + public final boolean onCommand( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String label, + @NotNull String[] args + ) { + if (sender instanceof Player player) { + if (!player.hasPermission(permission)) { + messages.send(sender, "no-permission", Map.of("permission", Component.text(permission))); + return false; + } + } else if (!(sender instanceof ConsoleCommandSender)) { + messages.send(sender, "unsupported-sender"); + return false; + } + + try { + return execute(sender, args); + } catch (ValidationException exception) { + messages.send(sender, exception.messageKey(), exception.placeholders()); + return false; + } catch (IOException exception) { + plugin.getLogger().log(Level.SEVERE, "Could not persist moderation state", exception); + messages.send(sender, "storage-error"); + return false; + } catch (RuntimeException exception) { + plugin.getLogger().log(Level.SEVERE, "Could not execute /" + label, exception); + messages.send(sender, "action-failed"); + return false; + } + } + + protected abstract boolean execute(CommandSender sender, String[] args) + throws ValidationException, IOException; + + protected ActorReasonParser.ActorReason parseActorAndReason( + CommandSender sender, + String[] arguments, + int startIndex, + boolean reasonRequired + ) throws ValidationException { + return actorReasonParser.parse( + sender instanceof ConsoleCommandSender, + sender.getName(), + arguments, + startIndex, + reasonRequired + ); + } + + @Override + public @NotNull List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args + ) { + return List.of(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/MuteCommand.java b/src/main/java/org/kilka/moderationcommands/command/MuteCommand.java new file mode 100644 index 0000000..5958ead --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/MuteCommand.java @@ -0,0 +1,99 @@ +package org.kilka.moderationcommands.command; + +import java.io.IOException; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import net.kyori.adventure.text.Component; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.kilka.moderationcommands.common.ActorReasonParser; +import org.kilka.moderationcommands.common.DurationFormatter; +import org.kilka.moderationcommands.common.DurationParser; +import org.kilka.moderationcommands.common.KnownPlayerResolver; +import org.kilka.moderationcommands.common.PlayerTarget; +import org.kilka.moderationcommands.common.PunishmentDuration; +import org.kilka.moderationcommands.common.ValidationException; +import org.kilka.moderationcommands.config.MessageService; +import org.kilka.moderationcommands.mute.MuteRecord; +import org.kilka.moderationcommands.mute.MuteLifecycleCoordinator; + +public final class MuteCommand extends ModerationCommand { + private final DurationParser durationParser; + private final DurationFormatter durationFormatter; + private final KnownPlayerResolver playerResolver; + private final MuteLifecycleCoordinator muteLifecycle; + private final Clock clock; + + public MuteCommand( + JavaPlugin plugin, + MessageService messages, + ActorReasonParser actorReasonParser, + DurationParser durationParser, + DurationFormatter durationFormatter, + KnownPlayerResolver playerResolver, + MuteLifecycleCoordinator muteLifecycle, + Clock clock + ) { + super(plugin, messages, actorReasonParser, "moderation.command.mute"); + this.durationParser = durationParser; + this.durationFormatter = durationFormatter; + this.playerResolver = playerResolver; + this.muteLifecycle = muteLifecycle; + this.clock = clock; + } + + @Override + protected boolean execute(CommandSender sender, String[] args) throws ValidationException, IOException { + if (args.length < 3) { + throw new ValidationException("usage-mute"); + } + PunishmentDuration duration = requireDuration(args[1]); + ActorReasonParser.ActorReason context = parseActorAndReason(sender, args, 2, true); + PlayerTarget target = playerResolver.resolve(args[0]).orElseThrow(() -> new ValidationException( + "target-not-found", + Map.of("target", Component.text(args[0])) + )); + + MuteRecord mute = muteLifecycle.mute(target, duration, context.actor(), context.reason()); + Map placeholders = Map.of( + "target", Component.text(mute.lastName()), + "actor", Component.text(context.actor()), + "reason", Component.text(context.reason()), + "duration", Component.text(durationFormatter.format(duration)) + ); + messages.send(sender, "mute-success", placeholders); + Player onlineTarget = plugin.getServer().getPlayer(target.uniqueId()); + if (onlineTarget != null) { + messages.send(onlineTarget, "mute-target", placeholders); + } + return true; + } + + private PunishmentDuration requireDuration(String input) throws ValidationException { + return durationParser.parse(input, clock.instant()).orElseThrow(() -> invalidDuration(input)); + } + + private ValidationException invalidDuration(String input) { + return new ValidationException("invalid-duration", Map.of("duration", Component.text(input))); + } + + @Override + public @NotNull List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args + ) { + if (args.length == 1) { + return CommandCompletions.onlinePlayers(plugin.getServer(), args[0]); + } + if (args.length == 2) { + return CommandCompletions.durations(args[1]); + } + return List.of(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/UnbanCommand.java b/src/main/java/org/kilka/moderationcommands/command/UnbanCommand.java new file mode 100644 index 0000000..34013c5 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/UnbanCommand.java @@ -0,0 +1,62 @@ +package org.kilka.moderationcommands.command; + +import com.destroystokyo.paper.profile.PlayerProfile; +import java.util.List; +import java.util.Map; +import net.kyori.adventure.text.Component; +import org.bukkit.BanEntry; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.kilka.moderationcommands.ban.BanService; +import org.kilka.moderationcommands.common.ActorReasonParser; +import org.kilka.moderationcommands.common.ValidationException; +import org.kilka.moderationcommands.config.MessageService; + +public final class UnbanCommand extends ModerationCommand { + private final BanService banService; + + public UnbanCommand( + JavaPlugin plugin, + MessageService messages, + ActorReasonParser actorReasonParser, + BanService banService + ) { + super(plugin, messages, actorReasonParser, "moderation.command.unban"); + this.banService = banService; + } + + @Override + protected boolean execute(CommandSender sender, String[] args) throws ValidationException { + if (args.length < 1) { + throw new ValidationException("usage-unban"); + } + ActorReasonParser.ActorReason context = parseActorAndReason(sender, args, 1, false); + BanEntry entry = banService.findActive(args[0]).orElseThrow(() -> new ValidationException( + "no-active-ban", + Map.of("target", Component.text(args[0])) + )); + String profileName = entry.getBanTarget().getName(); + String displayName = profileName == null ? entry.getBanTarget().getId().toString() : profileName; + banService.remove(entry); + messages.send(sender, "unban-success", Map.of( + "target", Component.text(displayName), + "actor", Component.text(context.actor()), + "reason", messages.optionalReason(context.reason()) + )); + return true; + } + + @Override + public @NotNull List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args + ) { + return args.length == 1 + ? CommandCompletions.filter(banService.activeProfileNames(), args[0]) + : List.of(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/command/UnmuteCommand.java b/src/main/java/org/kilka/moderationcommands/command/UnmuteCommand.java new file mode 100644 index 0000000..b496458 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/command/UnmuteCommand.java @@ -0,0 +1,73 @@ +package org.kilka.moderationcommands.command; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import net.kyori.adventure.text.Component; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.kilka.moderationcommands.common.ActorReasonParser; +import org.kilka.moderationcommands.common.ValidationException; +import org.kilka.moderationcommands.config.MessageService; +import org.kilka.moderationcommands.mute.MuteRecord; +import org.kilka.moderationcommands.mute.MuteLifecycleCoordinator; +import org.kilka.moderationcommands.mute.MuteService; + +public final class UnmuteCommand extends ModerationCommand { + private final MuteService muteService; + private final MuteLifecycleCoordinator muteLifecycle; + + public UnmuteCommand( + JavaPlugin plugin, + MessageService messages, + ActorReasonParser actorReasonParser, + MuteService muteService, + MuteLifecycleCoordinator muteLifecycle + ) { + super(plugin, messages, actorReasonParser, "moderation.command.unmute"); + this.muteService = muteService; + this.muteLifecycle = muteLifecycle; + } + + @Override + protected boolean execute(CommandSender sender, String[] args) throws ValidationException, IOException { + if (args.length < 1) { + throw new ValidationException("usage-unmute"); + } + ActorReasonParser.ActorReason context = parseActorAndReason(sender, args, 1, false); + MuteRecord mute = muteService.findActive(args[0]).orElseThrow(() -> new ValidationException( + "no-active-mute", + Map.of("target", Component.text(args[0])) + )); + if (!muteLifecycle.unmute(mute)) { + throw new ValidationException("no-active-mute", Map.of("target", Component.text(args[0]))); + } + + Map placeholders = Map.of( + "target", Component.text(mute.lastName()), + "actor", Component.text(context.actor()), + "reason", messages.optionalReason(context.reason()) + ); + messages.send(sender, "unmute-success", placeholders); + Player onlineTarget = plugin.getServer().getPlayer(mute.uniqueId()); + if (onlineTarget != null) { + messages.send(onlineTarget, "unmute-target", placeholders); + } + return true; + } + + @Override + public @NotNull List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args + ) { + return args.length == 1 + ? CommandCompletions.onlinePlayers(plugin.getServer(), args[0]) + : List.of(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/common/ActorReasonParser.java b/src/main/java/org/kilka/moderationcommands/common/ActorReasonParser.java new file mode 100644 index 0000000..975fca0 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/ActorReasonParser.java @@ -0,0 +1,49 @@ +package org.kilka.moderationcommands.common; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +public final class ActorReasonParser { + private static final String ACTOR_PREFIX = "actor:"; + private static final Pattern ACTOR_NAME = Pattern.compile("[A-Za-z0-9_]{1,16}"); + + public ActorReason parse( + boolean console, + String senderName, + String[] arguments, + int startIndex, + boolean reasonRequired + ) throws ValidationException { + String actor = console ? "CONSOLE" : senderName; + boolean actorSeen = false; + List reasonParts = new ArrayList<>(); + + for (int index = startIndex; index < arguments.length; index++) { + String argument = arguments[index]; + if (argument.toLowerCase(Locale.ROOT).startsWith(ACTOR_PREFIX)) { + if (!console) { + throw new ValidationException("player-actor-forbidden"); + } + String candidate = argument.substring(ACTOR_PREFIX.length()); + if (actorSeen || !ACTOR_NAME.matcher(candidate).matches()) { + throw new ValidationException("invalid-actor"); + } + actor = candidate; + actorSeen = true; + } else { + reasonParts.add(argument); + } + } + + String reason = String.join(" ", reasonParts).trim(); + if (reasonRequired && reason.isEmpty()) { + throw new ValidationException("missing-reason"); + } + return new ActorReason(actor, reason); + } + + public record ActorReason(String actor, String reason) { + } +} diff --git a/src/main/java/org/kilka/moderationcommands/common/BlockedCommandMatcher.java b/src/main/java/org/kilka/moderationcommands/common/BlockedCommandMatcher.java new file mode 100644 index 0000000..5e0aeaa --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/BlockedCommandMatcher.java @@ -0,0 +1,20 @@ +package org.kilka.moderationcommands.common; + +import java.util.Locale; +import java.util.Set; + +public final class BlockedCommandMatcher { + private final Set blockedCommands; + + public BlockedCommandMatcher(Set blockedCommands) { + this.blockedCommands = Set.copyOf(blockedCommands); + } + + public boolean isBlocked(String rawCommand) { + String withoutSlash = rawCommand.startsWith("/") ? rawCommand.substring(1) : rawCommand; + String root = withoutSlash.split("\\s+", 2)[0].toLowerCase(Locale.ROOT); + int namespaceSeparator = root.lastIndexOf(':'); + String withoutNamespace = namespaceSeparator >= 0 ? root.substring(namespaceSeparator + 1) : root; + return blockedCommands.contains(root) || blockedCommands.contains(withoutNamespace); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/common/DurationFormatter.java b/src/main/java/org/kilka/moderationcommands/common/DurationFormatter.java new file mode 100644 index 0000000..f9217b1 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/DurationFormatter.java @@ -0,0 +1,43 @@ +package org.kilka.moderationcommands.common; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public final class DurationFormatter { + private static final long[] UNIT_SECONDS = { + 365L * 24L * 60L * 60L, + 7L * 24L * 60L * 60L, + 24L * 60L * 60L, + 60L * 60L, + 60L, + 1L + }; + private static final String[] UNIT_NAMES = {"г", "н", "д", "ч", "м", "с"}; + + public String format(PunishmentDuration duration) { + return duration.permanent() ? "навсегда" : formatSeconds(duration.seconds()); + } + + public String formatRemaining(Instant expiresAt, Instant now) { + if (expiresAt == null) { + return "навсегда"; + } + long seconds = Math.max(1, Duration.between(now, expiresAt).toSeconds()); + return formatSeconds(seconds); + } + + private String formatSeconds(long totalSeconds) { + long remaining = totalSeconds; + List parts = new ArrayList<>(2); + for (int index = 0; index < UNIT_SECONDS.length && parts.size() < 2; index++) { + long value = remaining / UNIT_SECONDS[index]; + if (value > 0) { + parts.add(value + UNIT_NAMES[index]); + remaining %= UNIT_SECONDS[index]; + } + } + return String.join(" ", parts); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/common/DurationParser.java b/src/main/java/org/kilka/moderationcommands/common/DurationParser.java new file mode 100644 index 0000000..89569b6 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/DurationParser.java @@ -0,0 +1,56 @@ +package org.kilka.moderationcommands.common; + +import java.time.DateTimeException; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class DurationParser { + private static final Pattern TEMPORARY = Pattern.compile("^(\\d+)([smhdwy])$"); + + public Optional parse(String input) { + String normalized = input.toLowerCase(Locale.ROOT); + if (normalized.equals("permanent")) { + return Optional.of(PunishmentDuration.permanentDuration()); + } + + Matcher matcher = TEMPORARY.matcher(normalized); + if (!matcher.matches()) { + return Optional.empty(); + } + + try { + long amount = Long.parseLong(matcher.group(1)); + if (amount <= 0) { + return Optional.empty(); + } + long multiplier = switch (matcher.group(2).charAt(0)) { + case 's' -> 1L; + case 'm' -> 60L; + case 'h' -> 60L * 60L; + case 'd' -> 24L * 60L * 60L; + case 'w' -> 7L * 24L * 60L * 60L; + case 'y' -> 365L * 24L * 60L * 60L; + default -> throw new IllegalStateException("Unexpected duration unit"); + }; + return Optional.of(PunishmentDuration.temporary(Math.multiplyExact(amount, multiplier))); + } catch (NumberFormatException | ArithmeticException exception) { + return Optional.empty(); + } + } + + public Optional parse(String input, Instant issuedAt) { + Optional parsed = parse(input); + if (parsed.isEmpty()) { + return Optional.empty(); + } + try { + parsed.get().expiresAt(issuedAt); + return parsed; + } catch (DateTimeException exception) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/org/kilka/moderationcommands/common/KnownPlayerResolver.java b/src/main/java/org/kilka/moderationcommands/common/KnownPlayerResolver.java new file mode 100644 index 0000000..baafb2e --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/KnownPlayerResolver.java @@ -0,0 +1,66 @@ +package org.kilka.moderationcommands.common; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.entity.Player; + +public final class KnownPlayerResolver { + private final Server server; + + public KnownPlayerResolver(Server server) { + this.server = server; + } + + public Optional resolve(String input) { + UUID uuid = parseUuid(input); + if (uuid != null) { + return Optional.of(fromOfflinePlayer(server.getOfflinePlayer(uuid), input)); + } + + Player online = findOnlineByName(input); + if (online != null) { + return Optional.of(fromOfflinePlayer(online, input)); + } + + OfflinePlayer cached = server.getOfflinePlayerIfCached(input); + return cached == null ? Optional.empty() : Optional.of(fromOfflinePlayer(cached, input)); + } + + public Optional resolveOnline(String input) { + UUID uuid = parseUuid(input); + if (uuid != null) { + return Optional.ofNullable(server.getPlayer(uuid)); + } + return Optional.ofNullable(findOnlineByName(input)); + } + + private Player findOnlineByName(String input) { + Player exact = server.getPlayerExact(input); + if (exact != null) { + return exact; + } + return server.getOnlinePlayers().stream() + .filter(player -> player.getName().equalsIgnoreCase(input)) + .findFirst() + .orElse(null); + } + + private PlayerTarget fromOfflinePlayer(OfflinePlayer player, String fallback) { + String name = player.getName(); + return new PlayerTarget( + player.getUniqueId(), + name == null || name.isBlank() ? fallback : name, + player.getPlayerProfile() + ); + } + + public static UUID parseUuid(String input) { + try { + return UUID.fromString(input); + } catch (IllegalArgumentException exception) { + return null; + } + } +} diff --git a/src/main/java/org/kilka/moderationcommands/common/PlayerTarget.java b/src/main/java/org/kilka/moderationcommands/common/PlayerTarget.java new file mode 100644 index 0000000..bf32289 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/PlayerTarget.java @@ -0,0 +1,7 @@ +package org.kilka.moderationcommands.common; + +import com.destroystokyo.paper.profile.PlayerProfile; +import java.util.UUID; + +public record PlayerTarget(UUID uniqueId, String displayName, PlayerProfile profile) { +} diff --git a/src/main/java/org/kilka/moderationcommands/common/PunishmentDuration.java b/src/main/java/org/kilka/moderationcommands/common/PunishmentDuration.java new file mode 100644 index 0000000..04c05bc --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/PunishmentDuration.java @@ -0,0 +1,27 @@ +package org.kilka.moderationcommands.common; + +import java.time.DateTimeException; +import java.time.Instant; + +public record PunishmentDuration(boolean permanent, long seconds) { + public PunishmentDuration { + if (permanent && seconds != 0) { + throw new IllegalArgumentException("A permanent duration cannot contain seconds"); + } + if (!permanent && seconds <= 0) { + throw new IllegalArgumentException("A temporary duration must be positive"); + } + } + + public static PunishmentDuration permanentDuration() { + return new PunishmentDuration(true, 0); + } + + public static PunishmentDuration temporary(long seconds) { + return new PunishmentDuration(false, seconds); + } + + public Instant expiresAt(Instant issuedAt) throws DateTimeException { + return permanent ? null : issuedAt.plusSeconds(seconds); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/common/ValidationException.java b/src/main/java/org/kilka/moderationcommands/common/ValidationException.java new file mode 100644 index 0000000..7275e6e --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/common/ValidationException.java @@ -0,0 +1,27 @@ +package org.kilka.moderationcommands.common; + +import java.util.Map; +import net.kyori.adventure.text.Component; + +public final class ValidationException extends Exception { + private final String messageKey; + private final Map placeholders; + + public ValidationException(String messageKey) { + this(messageKey, Map.of()); + } + + public ValidationException(String messageKey, Map placeholders) { + super(messageKey); + this.messageKey = messageKey; + this.placeholders = Map.copyOf(placeholders); + } + + public String messageKey() { + return messageKey; + } + + public Map placeholders() { + return placeholders; + } +} diff --git a/src/main/java/org/kilka/moderationcommands/config/MessageService.java b/src/main/java/org/kilka/moderationcommands/config/MessageService.java new file mode 100644 index 0000000..f58e975 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/config/MessageService.java @@ -0,0 +1,118 @@ +package org.kilka.moderationcommands.config; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import org.bukkit.configuration.file.FileConfiguration; + +public final class MessageService { + private static final List REQUIRED_KEYS = List.of( + "no-permission", + "unsupported-sender", + "usage-mute", + "usage-unmute", + "usage-ban", + "usage-unban", + "usage-kick", + "invalid-duration", + "invalid-actor", + "player-actor-forbidden", + "missing-reason", + "target-not-found", + "target-offline", + "no-active-mute", + "no-active-ban", + "storage-error", + "action-failed", + "mute-success", + "mute-target", + "unmute-success", + "unmute-target", + "ban-success", + "ban-disconnect", + "unban-success", + "kick-success", + "kick-disconnect", + "muted-attempt", + "reason-not-specified" + ); + private static final List PLACEHOLDER_NAMES = List.of( + "permission", "duration", "target", "actor", "reason", "command" + ); + + private final MiniMessage miniMessage = MiniMessage.builder().strict(true).build(); + private final Component prefix; + private final Map templates; + + public MessageService(FileConfiguration configuration) { + String prefixTemplate = require(configuration, "messages.prefix"); + this.prefix = miniMessage.deserialize(prefixTemplate); + + Map loaded = new LinkedHashMap<>(); + for (String key : REQUIRED_KEYS) { + loaded.put(key, require(configuration, "messages." + key)); + } + this.templates = Map.copyOf(loaded); + + TagResolver.Builder dummyResolver = TagResolver.builder(); + for (String name : PLACEHOLDER_NAMES) { + dummyResolver.resolver(Placeholder.unparsed(name, "value")); + } + TagResolver resolver = dummyResolver.build(); + templates.forEach((key, template) -> { + try { + miniMessage.deserialize(template, resolver); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Invalid MiniMessage template messages." + key, exception); + } + }); + } + + public void send(Audience audience, String key, Map placeholders) { + audience.sendMessage(prefix.append(render(key, placeholders))); + } + + public void send(Audience audience, String key) { + send(audience, key, Map.of()); + } + + public Component render(String key, Map placeholders) { + TagResolver.Builder resolver = TagResolver.builder(); + for (String name : PLACEHOLDER_NAMES) { + resolver.resolver(Placeholder.component(name, placeholders.getOrDefault(name, Component.empty()))); + } + return miniMessage.deserialize(template(key), resolver.build()); + } + + public Component render(String key) { + return render(key, Map.of()); + } + + public Component optionalReason(String reason) { + if (!reason.isBlank()) { + return Component.text(reason); + } + return render("reason-not-specified"); + } + + private String template(String key) { + String template = templates.get(key); + if (template == null) { + throw new IllegalArgumentException("Unknown message key: " + key); + } + return template; + } + + private static String require(FileConfiguration configuration, String path) { + String value = configuration.getString(path); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing required configuration value: " + path); + } + return value; + } +} diff --git a/src/main/java/org/kilka/moderationcommands/config/ModerationSettings.java b/src/main/java/org/kilka/moderationcommands/config/ModerationSettings.java new file mode 100644 index 0000000..16dab0c --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/config/ModerationSettings.java @@ -0,0 +1,43 @@ +package org.kilka.moderationcommands.config; + +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.bukkit.configuration.file.FileConfiguration; + +public record ModerationSettings( + Set blockedCommands, + boolean flectoneEnabled, + boolean voiceEnabled, + String voicePermission +) { + public static ModerationSettings load(FileConfiguration configuration) { + Set blocked = configuration.getStringList("mute.blocked-commands").stream() + .map(ModerationSettings::normalizeCommand) + .collect(Collectors.toUnmodifiableSet()); + if (blocked.isEmpty()) { + throw new IllegalArgumentException("mute.blocked-commands must contain at least one command"); + } + String voicePermission = configuration.getString("mute.voice.permission", "voicechat.speak"); + if (voicePermission == null || voicePermission.isBlank() || voicePermission.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("mute.voice.permission must be a non-blank permission node"); + } + return new ModerationSettings( + blocked, + configuration.getBoolean("mute.integrations.flectone.enabled", true), + configuration.getBoolean("mute.voice.enabled", true), + voicePermission.trim().toLowerCase(Locale.ROOT) + ); + } + + private static String normalizeCommand(String command) { + String normalized = command.trim().toLowerCase(Locale.ROOT); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + if (normalized.isEmpty() || normalized.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Invalid blocked command: " + command); + } + return normalized; + } +} diff --git a/src/main/java/org/kilka/moderationcommands/integration/flectone/FlectoneChatMuteIntegration.java b/src/main/java/org/kilka/moderationcommands/integration/flectone/FlectoneChatMuteIntegration.java new file mode 100644 index 0000000..db8b823 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/integration/flectone/FlectoneChatMuteIntegration.java @@ -0,0 +1,101 @@ +package org.kilka.moderationcommands.integration.flectone; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.logging.Level; +import net.flectone.pulse.FlectonePulse; +import net.flectone.pulse.FlectonePulseAPI; +import net.flectone.pulse.annotation.Pulse; +import net.flectone.pulse.listener.PulseListener; +import net.flectone.pulse.model.entity.FEntity; +import net.flectone.pulse.model.event.Event; +import net.flectone.pulse.model.event.message.MessagePrepareEvent; +import net.flectone.pulse.platform.registry.ListenerRegistry; +import net.flectone.pulse.util.constant.ModuleName; +import org.bukkit.plugin.java.JavaPlugin; +import org.kilka.moderationcommands.mute.MuteService; +import org.kilka.moderationcommands.mute.MuteRecord; +import org.kilka.moderationcommands.mute.MutedAttemptNotifier; + +public final class FlectoneChatMuteIntegration implements AutoCloseable { + private final ChatListener listener; + + private FlectoneChatMuteIntegration(ChatListener listener) { + this.listener = listener; + } + + public static Optional start( + JavaPlugin plugin, + MuteService muteService, + MutedAttemptNotifier notifier + ) { + try { + FlectonePulse pulse = FlectonePulseAPI.getInstance(); + if (pulse == null || !pulse.isReady()) { + plugin.getLogger().warning("FlectonePulse is installed but its API is not ready; PACKET chat mute is disabled"); + return Optional.empty(); + } + + ChatListener listener = new ChatListener(muteService, notifier); + pulse.get(ListenerRegistry.class).registerPermanent(listener); + plugin.getLogger().info("FlectonePulse PACKET chat mute integration enabled"); + return Optional.of(new FlectoneChatMuteIntegration(listener)); + } catch (RuntimeException | LinkageError exception) { + plugin.getLogger().log( + Level.SEVERE, + "Could not enable FlectonePulse integration; Paper chat enforcement remains active", + exception + ); + return Optional.empty(); + } + } + + @Override + public void close() { + listener.disable(); + } + + public static final class ChatListener implements PulseListener { + private final Function> activeMuteLookup; + private final BiConsumer notifier; + private final AtomicBoolean enabled = new AtomicBoolean(true); + + ChatListener(MuteService muteService, MutedAttemptNotifier notifier) { + this(muteService::findActive, notifier::notify); + } + + ChatListener( + Function> activeMuteLookup, + BiConsumer notifier + ) { + this.activeMuteLookup = activeMuteLookup; + this.notifier = notifier; + } + + @Pulse(priority = Event.Priority.LOWEST) + public Event onMessagePrepare(MessagePrepareEvent event) { + if (!enabled.get() || event.moduleName() != ModuleName.MESSAGE_CHAT) { + return event; + } + + FEntity sender = event.eventMetadata().sender(); + if (sender.isUnknown()) { + return event; + } + + return activeMuteLookup.apply(sender.uuid()) + .map(mute -> { + notifier.accept(sender.uuid(), mute); + return event.withCancelled(true); + }) + .orElse(event); + } + + void disable() { + enabled.set(false); + } + } +} diff --git a/src/main/java/org/kilka/moderationcommands/integration/luckperms/LuckPermsVoiceMuteIntegration.java b/src/main/java/org/kilka/moderationcommands/integration/luckperms/LuckPermsVoiceMuteIntegration.java new file mode 100644 index 0000000..2b280e6 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/integration/luckperms/LuckPermsVoiceMuteIntegration.java @@ -0,0 +1,209 @@ +package org.kilka.moderationcommands.integration.luckperms; + +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.context.ContextCalculator; +import net.luckperms.api.model.user.User; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.types.PermissionNode; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.plugin.java.JavaPlugin; +import org.kilka.moderationcommands.mute.MuteLifecycleObserver; +import org.kilka.moderationcommands.mute.MuteRecord; +import org.kilka.moderationcommands.mute.MuteService; + +public final class LuckPermsVoiceMuteIntegration implements MuteLifecycleObserver, Listener, AutoCloseable { + private static final String OWNER_CONTEXT_KEY = "moderationcommands-muted"; + private static final String OWNER_CONTEXT_VALUE = "true"; + + private final JavaPlugin plugin; + private final LuckPerms luckPerms; + private final MuteService muteService; + private final String permission; + private final Map activeRecords = new ConcurrentHashMap<>(); + private final ContextCalculator contextCalculator; + + private LuckPermsVoiceMuteIntegration( + JavaPlugin plugin, + LuckPerms luckPerms, + MuteService muteService, + String permission + ) { + this.plugin = plugin; + this.luckPerms = luckPerms; + this.muteService = muteService; + this.permission = permission; + this.contextCalculator = (player, consumer) -> { + MuteRecord mute = activeRecords.get(player.getUniqueId()); + if (mute != null && mute.isActiveAt(muteService.now())) { + consumer.accept(OWNER_CONTEXT_KEY, OWNER_CONTEXT_VALUE); + } + }; + } + + public static Optional start( + JavaPlugin plugin, + MuteService muteService, + String permission + ) { + try { + RegisteredServiceProvider registration = plugin.getServer() + .getServicesManager() + .getRegistration(LuckPerms.class); + if (registration == null) { + plugin.getLogger().warning("LuckPerms is not available; Simple Voice Chat mute is disabled"); + return Optional.empty(); + } + + LuckPermsVoiceMuteIntegration integration = new LuckPermsVoiceMuteIntegration( + plugin, + registration.getProvider(), + muteService, + permission + ); + integration.enable(); + plugin.getLogger().info("Simple Voice Chat mute enabled through LuckPerms permission " + permission); + return Optional.of(integration); + } catch (RuntimeException | LinkageError exception) { + plugin.getLogger().log( + Level.SEVERE, + "Could not enable LuckPerms voice integration; chat mute remains active", + exception + ); + return Optional.empty(); + } + } + + private void enable() { + luckPerms.getContextManager().registerCalculator(contextCalculator); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + for (Player player : plugin.getServer().getOnlinePlayers()) { + reconcile(player); + } + } + + @Override + public void onMuteApplied(MuteRecord mute) { + Player player = plugin.getServer().getPlayer(mute.uniqueId()); + if (player == null) { + return; + } + activeRecords.put(mute.uniqueId(), mute); + applyOrLoad(player, mute); + } + + @Override + public void onMuteRemoved(UUID uniqueId) { + activeRecords.remove(uniqueId); + User user = luckPerms.getUserManager().getUser(uniqueId); + if (user != null) { + removeOwnedNodes(user); + } + Player player = plugin.getServer().getPlayer(uniqueId); + if (player != null) { + luckPerms.getContextManager().signalContextUpdate(player); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + plugin.getServer().getScheduler().runTask(plugin, () -> reconcile(event.getPlayer())); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + UUID uniqueId = event.getPlayer().getUniqueId(); + activeRecords.remove(uniqueId); + User user = luckPerms.getUserManager().getUser(uniqueId); + if (user != null) { + removeOwnedNodes(user); + } + } + + private void reconcile(Player player) { + Optional activeMute = muteService.findActive(player.getUniqueId()); + if (activeMute.isEmpty()) { + onMuteRemoved(player.getUniqueId()); + return; + } + MuteRecord mute = activeMute.get(); + activeRecords.put(player.getUniqueId(), mute); + applyOrLoad(player, mute); + } + + private void applyOrLoad(Player player, MuteRecord mute) { + User user = luckPerms.getUserManager().getUser(player.getUniqueId()); + if (user != null) { + applyNode(player, user, mute); + return; + } + + luckPerms.getUserManager().loadUser(player.getUniqueId()).whenComplete((loaded, failure) -> { + if (failure != null) { + plugin.getLogger().log(Level.SEVERE, "Could not load LuckPerms user " + player.getUniqueId(), failure); + return; + } + if (!plugin.isEnabled()) { + return; + } + plugin.getServer().getScheduler().runTask(plugin, () -> { + Player current = plugin.getServer().getPlayer(player.getUniqueId()); + if (current != null) { + reconcile(current); + } + }); + }); + } + + private void applyNode(Player player, User user, MuteRecord mute) { + removeOwnedNodes(user); + PermissionNode.Builder builder = PermissionNode.builder(permission) + .value(false) + .withContext(OWNER_CONTEXT_KEY, OWNER_CONTEXT_VALUE); + if (mute.expiresAt() != null) { + builder.expiry(mute.expiresAt()); + } + user.transientData().add(builder.build()); + luckPerms.getContextManager().signalContextUpdate(player); + } + + private void removeOwnedNodes(User user) { + for (Node node : new ArrayList<>(user.transientData().toCollection())) { + if (isOwnedNode(node)) { + user.transientData().remove(node); + } + } + } + + private boolean isOwnedNode(Node node) { + return node.getKey().equals(permission) + && !node.getValue() + && node.getContexts().contains(OWNER_CONTEXT_KEY, OWNER_CONTEXT_VALUE); + } + + @Override + public void close() { + HandlerList.unregisterAll(this); + for (Player player : plugin.getServer().getOnlinePlayers()) { + User user = luckPerms.getUserManager().getUser(player.getUniqueId()); + if (user != null) { + removeOwnedNodes(user); + } + luckPerms.getContextManager().signalContextUpdate(player); + } + activeRecords.clear(); + luckPerms.getContextManager().unregisterCalculator(contextCalculator); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/kick/KickService.java b/src/main/java/org/kilka/moderationcommands/kick/KickService.java new file mode 100644 index 0000000..d8b7469 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/kick/KickService.java @@ -0,0 +1,15 @@ +package org.kilka.moderationcommands.kick; + +import net.kyori.adventure.text.Component; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerKickEvent; + +public final class KickService { + public boolean kick(Player player, Component message) { + if (!player.isOnline()) { + return false; + } + player.kick(message, PlayerKickEvent.Cause.PLUGIN); + return true; + } +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/MuteEnforcementListener.java b/src/main/java/org/kilka/moderationcommands/mute/MuteEnforcementListener.java new file mode 100644 index 0000000..50ccecc --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/MuteEnforcementListener.java @@ -0,0 +1,43 @@ +package org.kilka.moderationcommands.mute; + +import io.papermc.paper.event.player.AsyncChatEvent; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.kilka.moderationcommands.common.BlockedCommandMatcher; + +public final class MuteEnforcementListener implements Listener { + private final MuteService muteService; + private final BlockedCommandMatcher commandMatcher; + private final MutedAttemptNotifier notifier; + + public MuteEnforcementListener( + MuteService muteService, + BlockedCommandMatcher commandMatcher, + MutedAttemptNotifier notifier + ) { + this.muteService = muteService; + this.commandMatcher = commandMatcher; + this.notifier = notifier; + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onChat(AsyncChatEvent event) { + muteService.findActive(event.getPlayer().getUniqueId()).ifPresent(mute -> { + event.setCancelled(true); + notifier.notify(event.getPlayer().getUniqueId(), mute); + }); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onCommand(PlayerCommandPreprocessEvent event) { + if (!commandMatcher.isBlocked(event.getMessage())) { + return; + } + muteService.findActive(event.getPlayer().getUniqueId()).ifPresent(mute -> { + event.setCancelled(true); + notifier.notify(event.getPlayer().getUniqueId(), mute); + }); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/MuteLifecycleCoordinator.java b/src/main/java/org/kilka/moderationcommands/mute/MuteLifecycleCoordinator.java new file mode 100644 index 0000000..669f101 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/MuteLifecycleCoordinator.java @@ -0,0 +1,69 @@ +package org.kilka.moderationcommands.mute; + +import java.io.IOException; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.kilka.moderationcommands.common.PlayerTarget; +import org.kilka.moderationcommands.common.PunishmentDuration; + +public final class MuteLifecycleCoordinator { + private final MuteService muteService; + private final List observers; + private final Logger logger; + + public MuteLifecycleCoordinator( + MuteService muteService, + List observers, + Logger logger + ) { + this.muteService = muteService; + this.observers = List.copyOf(observers); + this.logger = logger; + } + + public MuteRecord mute(PlayerTarget target, PunishmentDuration duration, String actor, String reason) + throws IOException { + MuteRecord mute = muteService.mute(target, duration, actor, reason); + notifyApplied(mute); + return mute; + } + + public boolean unmute(MuteRecord mute) throws IOException { + if (!muteService.unmute(mute)) { + return false; + } + notifyRemoved(Set.of(mute.uniqueId())); + return true; + } + + public Set purgeExpired() throws IOException { + Set expired = muteService.purgeExpired(); + notifyRemoved(expired); + return expired; + } + + private void notifyApplied(MuteRecord mute) { + for (MuteLifecycleObserver observer : observers) { + try { + observer.onMuteApplied(mute); + } catch (RuntimeException | LinkageError exception) { + logger.log(Level.SEVERE, "Mute was saved, but an optional integration failed to apply", exception); + } + } + } + + private void notifyRemoved(Set uniqueIds) { + for (UUID uniqueId : uniqueIds) { + for (MuteLifecycleObserver observer : observers) { + try { + observer.onMuteRemoved(uniqueId); + } catch (RuntimeException | LinkageError exception) { + logger.log(Level.SEVERE, "Mute was removed, but an optional integration failed to update", exception); + } + } + } + } +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/MuteLifecycleObserver.java b/src/main/java/org/kilka/moderationcommands/mute/MuteLifecycleObserver.java new file mode 100644 index 0000000..4e227b4 --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/MuteLifecycleObserver.java @@ -0,0 +1,11 @@ +package org.kilka.moderationcommands.mute; + +import java.util.UUID; + +public interface MuteLifecycleObserver { + default void onMuteApplied(MuteRecord mute) { + } + + default void onMuteRemoved(UUID uniqueId) { + } +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/MuteRecord.java b/src/main/java/org/kilka/moderationcommands/mute/MuteRecord.java new file mode 100644 index 0000000..8961baa --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/MuteRecord.java @@ -0,0 +1,35 @@ +package org.kilka.moderationcommands.mute; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record MuteRecord( + UUID uniqueId, + String lastName, + String actor, + String reason, + Instant issuedAt, + Instant expiresAt +) { + public MuteRecord { + Objects.requireNonNull(uniqueId, "uniqueId"); + requireText(lastName, "lastName"); + requireText(actor, "actor"); + requireText(reason, "reason"); + Objects.requireNonNull(issuedAt, "issuedAt"); + if (expiresAt != null && !expiresAt.isAfter(issuedAt)) { + throw new IllegalArgumentException("expiresAt must be after issuedAt"); + } + } + + public boolean isActiveAt(Instant instant) { + return expiresAt == null || expiresAt.isAfter(instant); + } + + private static void requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + } +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/MuteRepository.java b/src/main/java/org/kilka/moderationcommands/mute/MuteRepository.java new file mode 100644 index 0000000..b47606e --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/MuteRepository.java @@ -0,0 +1,19 @@ +package org.kilka.moderationcommands.mute; + +import java.io.IOException; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public interface MuteRepository { + Optional findActive(UUID uniqueId, Instant now); + + Optional findActiveByLastName(String name, Instant now); + + void upsert(MuteRecord mute) throws IOException; + + boolean remove(UUID uniqueId) throws IOException; + + Set purgeExpired(Instant now) throws IOException; +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/MuteService.java b/src/main/java/org/kilka/moderationcommands/mute/MuteService.java new file mode 100644 index 0000000..720796b --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/MuteService.java @@ -0,0 +1,66 @@ +package org.kilka.moderationcommands.mute; + +import java.io.IOException; +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.kilka.moderationcommands.common.KnownPlayerResolver; +import org.kilka.moderationcommands.common.PlayerTarget; +import org.kilka.moderationcommands.common.PunishmentDuration; + +public final class MuteService { + private final MuteRepository repository; + private final KnownPlayerResolver playerResolver; + private final Clock clock; + + public MuteService(MuteRepository repository, KnownPlayerResolver playerResolver, Clock clock) { + this.repository = repository; + this.playerResolver = playerResolver; + this.clock = clock; + } + + public MuteRecord mute(PlayerTarget target, PunishmentDuration duration, String actor, String reason) + throws IOException { + Instant issuedAt = clock.instant(); + MuteRecord mute = new MuteRecord( + target.uniqueId(), + target.displayName(), + actor, + reason, + issuedAt, + duration.expiresAt(issuedAt) + ); + repository.upsert(mute); + return mute; + } + + public Optional findActive(String targetInput) { + Instant now = clock.instant(); + Optional knownTarget = playerResolver.resolve(targetInput); + if (knownTarget.isPresent()) { + Optional byUuid = repository.findActive(knownTarget.get().uniqueId(), now); + if (byUuid.isPresent()) { + return byUuid; + } + } + return repository.findActiveByLastName(targetInput, now); + } + + public Optional findActive(java.util.UUID uniqueId) { + return repository.findActive(uniqueId, clock.instant()); + } + + public boolean unmute(MuteRecord mute) throws IOException { + return repository.remove(mute.uniqueId()); + } + + public Set purgeExpired() throws IOException { + return repository.purgeExpired(clock.instant()); + } + + public Instant now() { + return clock.instant(); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/MutedAttemptNotifier.java b/src/main/java/org/kilka/moderationcommands/mute/MutedAttemptNotifier.java new file mode 100644 index 0000000..b56f24e --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/MutedAttemptNotifier.java @@ -0,0 +1,48 @@ +package org.kilka.moderationcommands.mute; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.kyori.adventure.text.Component; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.kilka.moderationcommands.common.DurationFormatter; +import org.kilka.moderationcommands.config.MessageService; + +public final class MutedAttemptNotifier { + private final JavaPlugin plugin; + private final MuteService muteService; + private final DurationFormatter durationFormatter; + private final MessageService messages; + private final Set pending = ConcurrentHashMap.newKeySet(); + + public MutedAttemptNotifier( + JavaPlugin plugin, + MuteService muteService, + DurationFormatter durationFormatter, + MessageService messages + ) { + this.plugin = plugin; + this.muteService = muteService; + this.durationFormatter = durationFormatter; + this.messages = messages; + } + + public void notify(UUID uniqueId, MuteRecord mute) { + if (!pending.add(uniqueId)) { + return; + } + plugin.getServer().getScheduler().runTask(plugin, () -> { + pending.remove(uniqueId); + Player player = plugin.getServer().getPlayer(uniqueId); + if (player == null) { + return; + } + messages.send(player, "muted-attempt", Map.of( + "reason", Component.text(mute.reason()), + "duration", Component.text(durationFormatter.formatRemaining(mute.expiresAt(), muteService.now())) + )); + }); + } +} diff --git a/src/main/java/org/kilka/moderationcommands/mute/YamlMuteRepository.java b/src/main/java/org/kilka/moderationcommands/mute/YamlMuteRepository.java new file mode 100644 index 0000000..8f24c4b --- /dev/null +++ b/src/main/java/org/kilka/moderationcommands/mute/YamlMuteRepository.java @@ -0,0 +1,174 @@ +package org.kilka.moderationcommands.mute; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +public final class YamlMuteRepository implements MuteRepository { + private static final int DATA_VERSION = 1; + + private final Path dataFile; + private volatile Map records; + + public YamlMuteRepository(Path dataFile) throws IOException, InvalidConfigurationException { + this.dataFile = dataFile; + this.records = Map.copyOf(load()); + } + + @Override + public Optional findActive(UUID uniqueId, Instant now) { + MuteRecord mute = records.get(uniqueId); + return mute != null && mute.isActiveAt(now) ? Optional.of(mute) : Optional.empty(); + } + + @Override + public Optional findActiveByLastName(String name, Instant now) { + MuteRecord match = null; + for (MuteRecord mute : records.values()) { + if (mute.isActiveAt(now) && mute.lastName().equalsIgnoreCase(name)) { + if (match != null && !match.uniqueId().equals(mute.uniqueId())) { + return Optional.empty(); + } + match = mute; + } + } + return Optional.ofNullable(match); + } + + @Override + public synchronized void upsert(MuteRecord mute) throws IOException { + Map updated = new HashMap<>(records); + updated.put(mute.uniqueId(), mute); + persist(updated); + records = Map.copyOf(updated); + } + + @Override + public synchronized boolean remove(UUID uniqueId) throws IOException { + if (!records.containsKey(uniqueId)) { + return false; + } + Map updated = new HashMap<>(records); + updated.remove(uniqueId); + persist(updated); + records = Map.copyOf(updated); + return true; + } + + @Override + public synchronized Set purgeExpired(Instant now) throws IOException { + Map updated = new HashMap<>(records); + Set expired = updated.values().stream() + .filter(mute -> !mute.isActiveAt(now)) + .map(MuteRecord::uniqueId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (!expired.isEmpty()) { + expired.forEach(updated::remove); + persist(updated); + records = Map.copyOf(updated); + } + return expired; + } + + private Map load() throws IOException, InvalidConfigurationException { + if (!Files.exists(dataFile)) { + return Map.of(); + } + + YamlConfiguration yaml = new YamlConfiguration(); + yaml.load(dataFile.toFile()); + if (yaml.getInt("data-version", -1) != DATA_VERSION) { + throw new InvalidConfigurationException("Unsupported or missing mutes.yml data-version"); + } + + ConfigurationSection mutes = yaml.getConfigurationSection("mutes"); + if (mutes == null) { + return Map.of(); + } + + Map loaded = new HashMap<>(); + for (String uuidKey : mutes.getKeys(false)) { + try { + UUID uuid = UUID.fromString(uuidKey); + ConfigurationSection section = requireSection(mutes, uuidKey); + String lastName = requireString(section, "last-name"); + String actor = requireString(section, "actor"); + String reason = requireString(section, "reason"); + Instant issuedAt = Instant.parse(requireString(section, "issued-at")); + String expirationValue = section.getString("expires-at"); + Instant expiresAt = expirationValue == null ? null : Instant.parse(expirationValue); + MuteRecord previous = loaded.put( + uuid, + new MuteRecord(uuid, lastName, actor, reason, issuedAt, expiresAt) + ); + if (previous != null) { + throw new InvalidConfigurationException("Duplicate mute UUID: " + uuid); + } + } catch (IllegalArgumentException | DateTimeException exception) { + throw new InvalidConfigurationException("Invalid mute entry: " + uuidKey, exception); + } + } + return loaded; + } + + private void persist(Map snapshot) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("data-version", DATA_VERSION); + for (MuteRecord mute : snapshot.values()) { + String path = "mutes." + mute.uniqueId(); + yaml.set(path + ".last-name", mute.lastName()); + yaml.set(path + ".actor", mute.actor()); + yaml.set(path + ".reason", mute.reason()); + yaml.set(path + ".issued-at", mute.issuedAt().toString()); + if (mute.expiresAt() != null) { + yaml.set(path + ".expires-at", mute.expiresAt().toString()); + } + } + + Path parent = dataFile.getParent(); + Files.createDirectories(parent); + Path temporaryFile = parent.resolve(dataFile.getFileName() + ".tmp"); + Files.writeString(temporaryFile, yaml.saveToString(), StandardCharsets.UTF_8); + try { + Files.move( + temporaryFile, + dataFile, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporaryFile, dataFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static ConfigurationSection requireSection(ConfigurationSection parent, String key) + throws InvalidConfigurationException { + ConfigurationSection section = parent.getConfigurationSection(key); + if (section == null) { + throw new InvalidConfigurationException("Expected section at mutes." + key); + } + return section; + } + + private static String requireString(ConfigurationSection section, String key) + throws InvalidConfigurationException { + String value = section.getString(key); + if (value == null || value.isBlank()) { + throw new InvalidConfigurationException("Missing or blank field " + section.getCurrentPath() + "." + key); + } + return value; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..e702523 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,46 @@ +mute: + integrations: + flectone: + enabled: true + voice: + enabled: true + permission: voicechat.speak + blocked-commands: + - msg + - tell + - w + - whisper + - reply + - r + - me + +messages: + prefix: "[Moderation] " + no-permission: "У вас нет права ." + unsupported-sender: "Эту команду может выполнить только игрок или обычная консоль." + usage-mute: "Использование: /mute [actor:] " + usage-unmute: "Использование: /unmute [actor:] [reason...]" + usage-ban: "Использование: /ban [actor:] " + usage-unban: "Использование: /unban [actor:] [reason...]" + usage-kick: "Использование: /kick [actor:] " + invalid-duration: "Неизвестный или недопустимый срок: ." + invalid-actor: "Аргумент actor: указан неверно." + player-actor-forbidden: "Игрок не может передавать actor: вручную." + missing-reason: "Необходимо указать причину." + target-not-found: "Профиль не найден среди известных серверу игроков. Можно указать UUID." + target-offline: "Игрок уже вышел с сервера." + no-active-mute: "У игрока нет активного мута." + no-active-ban: "У профиля нет активного бана." + storage-error: "Не удалось надёжно сохранить изменение. Наказание не применено." + action-failed: "Не удалось применить действие. Подробности записаны в журнал сервера." + mute-success: "Мут игрока установлен на . Автор: ." + mute-target: "Вам запрещён чат. Причина: . Модератор: . Срок: ." + unmute-success: "Мут игрока снят. Автор: ." + unmute-target: "Ваш мут снят. Модератор: . Причина снятия: ." + ban-success: "Профиль заблокирован на . Автор: ." + ban-disconnect: "Вы заблокированы на сервере.\nПричина: \nМодератор: \nСрок: " + unban-success: "Бан профиля снят. Автор: ." + kick-success: "Игрок исключён с сервера. Автор: ." + kick-disconnect: "Вы исключены с сервера.\nПричина: \nМодератор: " + muted-attempt: "Вы не можете писать. Причина: . Осталось: ." + reason-not-specified: "не указана" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..62a3878 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,54 @@ +name: ModerationCommands +description: $description +prefix: ModerationCommands +version: '${version}' + +main: org.kilka.moderationcommands.ModerationCommandsPlugin +api-version: '26.1.2' +load: POSTWORLD +softdepend: [ FlectonePulse, LuckPerms ] + +authors: [ Kilka_v_HJIebe ] + +commands: + mute: + description: Mute a player + permission: moderation.command.mute + unmute: + description: Remove an active mute + permission: moderation.command.unmute + ban: + description: Ban a player profile + permission: moderation.command.ban + unban: + description: Remove an active profile ban + permission: moderation.command.unban + kick: + description: Kick an online player + permission: moderation.command.kick + +permissions: + moderation.command.*: + description: Allows all ModerationCommands commands + default: op + children: + moderation.command.mute: true + moderation.command.unmute: true + moderation.command.ban: true + moderation.command.unban: true + moderation.command.kick: true + moderation.command.mute: + description: Allows /mute + default: op + moderation.command.unmute: + description: Allows /unmute + default: op + moderation.command.ban: + description: Allows /ban + default: op + moderation.command.unban: + description: Allows /unban + default: op + moderation.command.kick: + description: Allows /kick + default: op diff --git a/src/test/java/org/kilka/moderationcommands/common/ActorReasonParserTest.java b/src/test/java/org/kilka/moderationcommands/common/ActorReasonParserTest.java new file mode 100644 index 0000000..3428475 --- /dev/null +++ b/src/test/java/org/kilka/moderationcommands/common/ActorReasonParserTest.java @@ -0,0 +1,70 @@ +package org.kilka.moderationcommands.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class ActorReasonParserTest { + private final ActorReasonParser parser = new ActorReasonParser(); + + @Test + void extractsConsoleActorAndRemovesItFromReason() throws Exception { + ActorReasonParser.ActorReason result = parser.parse( + true, + "Console", + new String[] {"Steve", "1h", "флуд", "actor:Moderator", "в", "чате"}, + 2, + true + ); + + assertEquals("Moderator", result.actor()); + assertEquals("флуд в чате", result.reason()); + } + + @Test + void usesConsoleWhenActorIsAbsent() throws Exception { + ActorReasonParser.ActorReason result = parser.parse( + true, + "Console", + new String[] {"Steve", "нарушение"}, + 1, + true + ); + + assertEquals("CONSOLE", result.actor()); + assertEquals("нарушение", result.reason()); + } + + @Test + void rejectsActorFromPlayerWithoutChangingTheAuthor() { + ValidationException exception = assertThrows(ValidationException.class, () -> parser.parse( + false, + "RealModerator", + new String[] {"Steve", "actor:Fake", "причина"}, + 1, + true + )); + + assertEquals("player-actor-forbidden", exception.messageKey()); + } + + @Test + void rejectsDuplicateMalformedAndMissingValues() { + assertMessageKey("invalid-actor", new String[] {"actor:One", "actor:Two", "reason"}, false); + assertMessageKey("invalid-actor", new String[] {"actor:", "reason"}, false); + assertMessageKey("invalid-actor", new String[] {"actor:имя", "reason"}, false); + assertMessageKey("missing-reason", new String[] {"actor:Moderator"}, true); + } + + private void assertMessageKey(String expected, String[] arguments, boolean reasonRequired) { + ValidationException exception = assertThrows(ValidationException.class, () -> parser.parse( + true, + "Console", + arguments, + 0, + reasonRequired + )); + assertEquals(expected, exception.messageKey()); + } +} diff --git a/src/test/java/org/kilka/moderationcommands/common/BlockedCommandMatcherTest.java b/src/test/java/org/kilka/moderationcommands/common/BlockedCommandMatcherTest.java new file mode 100644 index 0000000..a73af2d --- /dev/null +++ b/src/test/java/org/kilka/moderationcommands/common/BlockedCommandMatcherTest.java @@ -0,0 +1,26 @@ +package org.kilka.moderationcommands.common; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import org.junit.jupiter.api.Test; + +class BlockedCommandMatcherTest { + private final BlockedCommandMatcher matcher = new BlockedCommandMatcher(Set.of("msg", "me", "essentials:reply")); + + @Test + void blocksPlainCaseInsensitiveAndNamespacedCommands() { + assertTrue(matcher.isBlocked("/msg Steve hello")); + assertTrue(matcher.isBlocked("/MSG Steve hello")); + assertTrue(matcher.isBlocked("/minecraft:me waves")); + assertTrue(matcher.isBlocked("/essentials:reply hello")); + } + + @Test + void doesNotMatchArgumentsOrPrefixes() { + assertFalse(matcher.isBlocked("/message Steve hello")); + assertFalse(matcher.isBlocked("/help msg")); + assertFalse(matcher.isBlocked("/msgpack reload")); + } +} diff --git a/src/test/java/org/kilka/moderationcommands/common/DurationParserTest.java b/src/test/java/org/kilka/moderationcommands/common/DurationParserTest.java new file mode 100644 index 0000000..a371b36 --- /dev/null +++ b/src/test/java/org/kilka/moderationcommands/common/DurationParserTest.java @@ -0,0 +1,37 @@ +package org.kilka.moderationcommands.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class DurationParserTest { + private final DurationParser parser = new DurationParser(); + + @Test + void parsesRequiredAndGeneralizedDurations() { + assertEquals(3_600, parser.parse("1h").orElseThrow().seconds()); + assertEquals(86_400, parser.parse("1d").orElseThrow().seconds()); + assertEquals(604_800, parser.parse("7d").orElseThrow().seconds()); + assertEquals(2_592_000, parser.parse("30d").orElseThrow().seconds()); + assertEquals(31_536_000, parser.parse("1y").orElseThrow().seconds()); + assertEquals(90, parser.parse("90S").orElseThrow().seconds()); + assertTrue(parser.parse("PERMANENT").orElseThrow().permanent()); + } + + @Test + void rejectsInvalidValuesAndArithmeticOverflow() { + for (String input : new String[] {"0h", "-1h", "+1h", "hour", "1x", "", "999999999999999999999999h"}) { + assertTrue(parser.parse(input).isEmpty(), input); + } + assertTrue(parser.parse("9223372036854775807y").isEmpty()); + } + + @Test + void rejectsExpirationOverflow() { + assertFalse(parser.parse("1s", Instant.MAX).isPresent()); + assertTrue(parser.parse("1s", Instant.EPOCH).isPresent()); + } +} diff --git a/src/test/java/org/kilka/moderationcommands/config/DefaultConfigTest.java b/src/test/java/org/kilka/moderationcommands/config/DefaultConfigTest.java new file mode 100644 index 0000000..695d662 --- /dev/null +++ b/src/test/java/org/kilka/moderationcommands/config/DefaultConfigTest.java @@ -0,0 +1,53 @@ +package org.kilka.moderationcommands.config; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class DefaultConfigTest { + @Test + void defaultConfigContainsRequiredCommandsAndValidMessages() { + YamlConfiguration configuration = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"), + StandardCharsets.UTF_8 + )); + + ModerationSettings settings = ModerationSettings.load(configuration); + assertTrue(settings.blockedCommands().containsAll(Set.of( + "msg", "tell", "w", "whisper", "reply", "r", "me" + ))); + assertTrue(settings.flectoneEnabled()); + assertTrue(settings.voiceEnabled()); + assertEquals("voicechat.speak", settings.voicePermission()); + assertDoesNotThrow(() -> new MessageService(configuration)); + } + + @Test + void missingIntegrationSettingsUseBackwardsCompatibleDefaults() { + YamlConfiguration configuration = new YamlConfiguration(); + configuration.set("mute.blocked-commands", List.of("msg")); + + ModerationSettings settings = ModerationSettings.load(configuration); + + assertTrue(settings.flectoneEnabled()); + assertTrue(settings.voiceEnabled()); + assertEquals("voicechat.speak", settings.voicePermission()); + } + + @Test + void rejectsInvalidVoicePermission() { + YamlConfiguration configuration = new YamlConfiguration(); + configuration.set("mute.blocked-commands", List.of("msg")); + configuration.set("mute.voice.permission", "voicechat speak"); + + assertThrows(IllegalArgumentException.class, () -> ModerationSettings.load(configuration)); + } +} diff --git a/src/test/java/org/kilka/moderationcommands/config/PluginDescriptorTest.java b/src/test/java/org/kilka/moderationcommands/config/PluginDescriptorTest.java new file mode 100644 index 0000000..492e19a --- /dev/null +++ b/src/test/java/org/kilka/moderationcommands/config/PluginDescriptorTest.java @@ -0,0 +1,32 @@ +package org.kilka.moderationcommands.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class PluginDescriptorTest { + private static final List COMMANDS = List.of("mute", "unmute", "ban", "unban", "kick"); + + @Test + void commandsExposeTheSamePermissionsCheckedByExecutors() { + YamlConfiguration descriptor = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("plugin.yml"), + StandardCharsets.UTF_8 + )); + + for (String command : COMMANDS) { + assertEquals( + "moderation.command." + command, + descriptor.getString("commands." + command + ".permission") + ); + assertTrue(descriptor.getBoolean( + "permissions.moderation.command.*.children.moderation.command." + command + )); + } + } +} diff --git a/src/test/java/org/kilka/moderationcommands/integration/flectone/FlectoneChatMuteIntegrationTest.java b/src/test/java/org/kilka/moderationcommands/integration/flectone/FlectoneChatMuteIntegrationTest.java new file mode 100644 index 0000000..caabfb0 --- /dev/null +++ b/src/test/java/org/kilka/moderationcommands/integration/flectone/FlectoneChatMuteIntegrationTest.java @@ -0,0 +1,88 @@ +package org.kilka.moderationcommands.integration.flectone; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import net.flectone.pulse.config.setting.LocalizationSetting; +import net.flectone.pulse.model.entity.FEntity; +import net.flectone.pulse.model.event.Event; +import net.flectone.pulse.model.event.EventMetadata; +import net.flectone.pulse.model.event.message.MessagePrepareEvent; +import net.flectone.pulse.util.constant.ModuleName; +import org.junit.jupiter.api.Test; +import org.kilka.moderationcommands.mute.MuteRecord; + +class FlectoneChatMuteIntegrationTest { + private static final UUID PLAYER_ID = UUID.fromString("8decc9a3-91ca-4a26-8b27-dca74789947f"); + private static final MuteRecord ACTIVE_MUTE = new MuteRecord( + PLAYER_ID, + "Steve", + "Moderator", + "флуд", + Instant.parse("2026-08-02T12:00:00Z"), + Instant.parse("2026-08-02T13:00:00Z") + ); + + @Test + void cancelsFlectoneChatAndNotifiesMutedSender() { + AtomicReference notification = new AtomicReference<>(); + FlectoneChatMuteIntegration.ChatListener listener = new FlectoneChatMuteIntegration.ChatListener( + uniqueId -> uniqueId.equals(PLAYER_ID) ? Optional.of(ACTIVE_MUTE) : Optional.empty(), + (uniqueId, mute) -> notification.set(mute) + ); + + Event result = listener.onMessagePrepare(event(ModuleName.MESSAGE_CHAT)); + + assertTrue(result.cancelled()); + assertSame(ACTIVE_MUTE, notification.get()); + } + + @Test + void leavesOtherModulesAndUnmutedPlayersUntouched() { + FlectoneChatMuteIntegration.ChatListener listener = new FlectoneChatMuteIntegration.ChatListener( + uniqueId -> Optional.empty(), + (uniqueId, mute) -> { + throw new AssertionError("Unexpected notification"); + } + ); + MessagePrepareEvent otherModule = event(ModuleName.COMMAND_TELL); + MessagePrepareEvent chat = event(ModuleName.MESSAGE_CHAT); + + assertSame(otherModule, listener.onMessagePrepare(otherModule)); + Event chatResult = listener.onMessagePrepare(chat); + assertSame(chat, chatResult); + assertFalse(chatResult.cancelled()); + } + + @Test + void disabledPermanentListenerBecomesNoOp() { + FlectoneChatMuteIntegration.ChatListener listener = new FlectoneChatMuteIntegration.ChatListener( + uniqueId -> Optional.of(ACTIVE_MUTE), + (uniqueId, mute) -> { + throw new AssertionError("Unexpected notification"); + } + ); + listener.disable(); + MessagePrepareEvent chat = event(ModuleName.MESSAGE_CHAT); + + assertSame(chat, listener.onMessagePrepare(chat)); + } + + private static MessagePrepareEvent event(ModuleName moduleName) { + FEntity sender = FEntity.builder() + .uuid(PLAYER_ID) + .name("Steve") + .type("PLAYER") + .build(); + EventMetadata metadata = EventMetadata.builder() + .sender(sender) + .format("") + .build(); + return new MessagePrepareEvent(moduleName, "", metadata); + } +} diff --git a/src/test/java/org/kilka/moderationcommands/mute/YamlMuteRepositoryTest.java b/src/test/java/org/kilka/moderationcommands/mute/YamlMuteRepositoryTest.java new file mode 100644 index 0000000..c3efd50 --- /dev/null +++ b/src/test/java/org/kilka/moderationcommands/mute/YamlMuteRepositoryTest.java @@ -0,0 +1,109 @@ +package org.kilka.moderationcommands.mute; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.UUID; +import org.bukkit.configuration.InvalidConfigurationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class YamlMuteRepositoryTest { + @TempDir + Path temporaryDirectory; + + @Test + void persistsTemporaryAndPermanentMutesAcrossReloads() throws Exception { + Path file = temporaryDirectory.resolve("mutes.yml"); + UUID temporaryId = UUID.randomUUID(); + UUID permanentId = UUID.randomUUID(); + Instant issuedAt = Instant.parse("2026-08-02T12:00:00Z"); + + YamlMuteRepository repository = new YamlMuteRepository(file); + repository.upsert(new MuteRecord( + temporaryId, + "Steve", + "Moderator", + "флуд", + issuedAt, + issuedAt.plusSeconds(3_600) + )); + repository.upsert(new MuteRecord( + permanentId, + "Alex", + "CONSOLE", + "спам", + issuedAt, + null + )); + + YamlMuteRepository reloaded = new YamlMuteRepository(file); + assertEquals("Steve", reloaded.findActive(temporaryId, issuedAt).orElseThrow().lastName()); + assertEquals("Alex", reloaded.findActiveByLastName("alex", issuedAt).orElseThrow().lastName()); + assertEquals(null, reloaded.findActive(permanentId, issuedAt).orElseThrow().expiresAt()); + } + + @Test + void repeatMuteReplacesTheUuidEntry() throws Exception { + Path file = temporaryDirectory.resolve("mutes.yml"); + UUID uuid = UUID.randomUUID(); + Instant issuedAt = Instant.parse("2026-08-02T12:00:00Z"); + YamlMuteRepository repository = new YamlMuteRepository(file); + + repository.upsert(new MuteRecord(uuid, "OldName", "First", "old", issuedAt, null)); + repository.upsert(new MuteRecord(uuid, "NewName", "Second", "new", issuedAt.plusSeconds(1), null)); + + MuteRecord actual = new YamlMuteRepository(file).findActive(uuid, issuedAt.plusSeconds(2)).orElseThrow(); + assertEquals("NewName", actual.lastName()); + assertEquals("Second", actual.actor()); + assertEquals("new", actual.reason()); + } + + @Test + void expirationIsImmediateAndCleanupIsPersistent() throws Exception { + Path file = temporaryDirectory.resolve("mutes.yml"); + UUID uuid = UUID.randomUUID(); + Instant issuedAt = Instant.parse("2026-08-02T12:00:00Z"); + YamlMuteRepository repository = new YamlMuteRepository(file); + repository.upsert(new MuteRecord(uuid, "Steve", "Moderator", "reason", issuedAt, issuedAt.plusSeconds(1))); + + assertTrue(repository.findActive(uuid, issuedAt).isPresent()); + assertFalse(repository.findActive(uuid, issuedAt.plusSeconds(1)).isPresent()); + assertEquals(java.util.Set.of(uuid), repository.purgeExpired(issuedAt.plusSeconds(1))); + assertFalse(new YamlMuteRepository(file).findActive(uuid, issuedAt).isPresent()); + } + + @Test + void failedAtomicWriteKeepsThePublishedSnapshot() throws Exception { + Path file = temporaryDirectory.resolve("mutes.yml"); + Path temporaryFile = temporaryDirectory.resolve("mutes.yml.tmp"); + UUID originalId = UUID.randomUUID(); + UUID rejectedId = UUID.randomUUID(); + Instant issuedAt = Instant.parse("2026-08-02T12:00:00Z"); + YamlMuteRepository repository = new YamlMuteRepository(file); + repository.upsert(new MuteRecord(originalId, "Steve", "Moderator", "first", issuedAt, null)); + Files.createDirectory(temporaryFile); + + assertThrows(IOException.class, () -> repository.upsert( + new MuteRecord(rejectedId, "Alex", "Moderator", "second", issuedAt, null) + )); + assertTrue(repository.findActive(originalId, issuedAt).isPresent()); + assertFalse(repository.findActive(rejectedId, issuedAt).isPresent()); + assertFalse(new YamlMuteRepository(file).findActive(rejectedId, issuedAt).isPresent()); + } + + @Test + void rejectsCorruptedStorageWithoutOverwritingIt() throws IOException { + Path file = temporaryDirectory.resolve("mutes.yml"); + Files.writeString(file, "data-version: 999\nmutes: {}\n"); + + assertThrows(InvalidConfigurationException.class, () -> new YamlMuteRepository(file)); + assertTrue(Files.readString(file).contains("data-version: 999")); + } +}