From 6171162ae9e954e2fe068a23ecc8dd8bac2c0bbb Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 27 Sep 2026 19:07:06 +0100 Subject: [PATCH] zio fork: @ staff chat while gagged, gag message with time left and reason Gagged or silenced players can still send @ team chat to online staff. The chat-blocked message now says how long the gag or silence has left, or that it's permanent, and why. Reasons are kept in memory alongside each penalty. Adds release.sh, which ships CS2-SimpleAdmin.dll and lang/en.json. --- .gitignore | 2 + CS2-SimpleAdmin/CS2-SimpleAdmin.cs | 2 +- CS2-SimpleAdmin/CS2-SimpleAdmin.csproj | 2 +- CS2-SimpleAdmin/Commands/basecomms.cs | 6 +- CS2-SimpleAdmin/Events.cs | 38 +++++- CS2-SimpleAdmin/Managers/PlayerManager.cs | 8 +- .../Managers/PlayerPenaltyManager.cs | 43 +++++++ CS2-SimpleAdmin/lang/en.json | 5 + README.md | 18 +++ release.sh | 117 ++++++++++++++++++ 10 files changed, 230 insertions(+), 11 deletions(-) create mode 100755 release.sh diff --git a/.gitignore b/.gitignore index 1850b2f..55358e7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ CS2-SimpleAdmin_BanSoundModule — kopia CLAUDE.md /Modules/CS2-SimpleAdmin_BanSoundModule /Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN + +release-out/ diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs index a2a82ee..19a5546 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs @@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleAuthor => "daffyy"; - public override string ModuleVersion => "1.8.2b"; + public override string ModuleVersion => "1.8.2b-zio1"; public override void Load(bool hotReload) { diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj b/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj index 4284ae6..a3ad25d 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj @@ -19,7 +19,7 @@ - + none runtime compile; build; native; contentfiles; analyzers; buildtransitive diff --git a/CS2-SimpleAdmin/Commands/basecomms.cs b/CS2-SimpleAdmin/Commands/basecomms.cs index e202c1e..6305f40 100644 --- a/CS2-SimpleAdmin/Commands/basecomms.cs +++ b/CS2-SimpleAdmin/Commands/basecomms.cs @@ -89,7 +89,7 @@ public partial class CS2_SimpleAdmin }); // Add penalty to the player's penalty manager - PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Gag, Time.ActualDateTime().AddMinutes(time), time); + PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Gag, Time.ActualDateTime().AddMinutes(time), time, reason); // Determine message keys and arguments based on gag time (permanent or timed) var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 @@ -398,7 +398,7 @@ public partial class CS2_SimpleAdmin }); // Add penalty to the player's penalty manager - PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Mute, Time.ActualDateTime().AddMinutes(time), time); + PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Mute, Time.ActualDateTime().AddMinutes(time), time, reason); // Determine message keys and arguments based on mute time (permanent or timed) var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 @@ -709,7 +709,7 @@ public partial class CS2_SimpleAdmin }); // Add penalty to the player's penalty manager - PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Silence, Time.ActualDateTime().AddMinutes(time), time); + PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Silence, Time.ActualDateTime().AddMinutes(time), time, reason); player.VoiceFlags = VoiceFlags.Muted; // Determine message keys and arguments based on silence time (permanent or timed) diff --git a/CS2-SimpleAdmin/Events.cs b/CS2-SimpleAdmin/Events.cs index 8d0149a..ede2121 100644 --- a/CS2-SimpleAdmin/Events.cs +++ b/CS2-SimpleAdmin/Events.cs @@ -347,13 +347,15 @@ public partial class CS2_SimpleAdmin return HookResult.Continue; } + // zio: gagged players can still reach staff with @ in team chat, handled below. + var staffChat = command == "say_team" && info.GetArg(1).StartsWith('@'); // if (!Config.OtherSettings.UserMessageGagChatType) // { - if (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) || - PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime)) + if (!staffChat && (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) || + PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime))) { if (_localizer != null && endDateTime is not null) - player.SendLocalizedMessage(_localizer, "sa_player_penalty_chat_active", endDateTime.Value.ToString("g", player.GetLanguage())); + SendChatPenaltyMessage(player, endDateTime.Value); return HookResult.Stop; } // } @@ -388,6 +390,36 @@ public partial class CS2_SimpleAdmin return HookResult.Stop; } + // zio: says how long the gag (or silence) has left and why, in place of its end date. + private void SendChatPenaltyMessage(CCSPlayerController player, DateTime endDateTime) + { + var type = PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out var gagEnd) && gagEnd == endDateTime + ? PenaltyType.Gag + : PenaltyType.Silence; + var (remaining, reason) = PlayerPenaltyManager.GetPenaltyDetails(player.Slot, type, endDateTime); + var kind = type == PenaltyType.Gag ? "gag" : "silence"; + using (new WithTemporaryCulture(player.GetLanguage())) + { + if (string.IsNullOrWhiteSpace(reason)) + reason = _localizer!["sa_player_penalty_no_reason"]; + if (remaining is null) + player.SendLocalizedMessage(_localizer, $"sa_player_penalty_{kind}_active_perm", reason); + else + player.SendLocalizedMessage(_localizer, $"sa_player_penalty_{kind}_active", FormatRemaining(remaining.Value), reason); + } + } + + private static string FormatRemaining(TimeSpan remaining) + { + if (remaining.TotalMinutes < 1) + return "less than a minute"; + var parts = new List(); + if (remaining.Days > 0) parts.Add($"{remaining.Days}d"); + if (remaining.Hours > 0) parts.Add($"{remaining.Hours}h"); + if (remaining.Minutes > 0 && remaining.Days == 0) parts.Add($"{remaining.Minutes}m"); + return string.Join(" ", parts); + } + /*public HookResult OnCommandSay(CCSPlayerController? player, CommandInfo info) { if (player == null || !player.IsValid || player.IsBot) diff --git a/CS2-SimpleAdmin/Managers/PlayerManager.cs b/CS2-SimpleAdmin/Managers/PlayerManager.cs index 1736112..f2aba1d 100644 --- a/CS2-SimpleAdmin/Managers/PlayerManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerManager.cs @@ -125,20 +125,22 @@ internal class PlayerManager string muteType = mute.type; DateTime ends = mute.ends; int duration = mute.duration; + string? reason = mute.reason; + int passed = mute.passed is null ? 0 : (int)mute.passed; switch (muteType) { // Apply mute penalty based on mute type case "GAG": PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, - PenaltyType.Gag, ends, duration); + PenaltyType.Gag, ends, duration, reason, duration - passed); // if (CS2_SimpleAdmin._localizer != null) // mutesList[PenaltyType.Gag].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_gag", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]); break; case "MUTE": PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, - PenaltyType.Mute, ends, duration); + PenaltyType.Mute, ends, duration, reason, duration - passed); await Server.NextWorldUpdateAsync(() => { player.VoiceFlags = VoiceFlags.Muted; @@ -149,7 +151,7 @@ internal class PlayerManager default: PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, - PenaltyType.Silence, ends, duration); + PenaltyType.Silence, ends, duration, reason, duration - passed); await Server.NextWorldUpdateAsync(() => { player.VoiceFlags = VoiceFlags.Muted; diff --git a/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs b/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs index 2aab682..7931fe3 100644 --- a/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs @@ -8,6 +8,46 @@ public static class PlayerPenaltyManager private static readonly ConcurrentDictionary>> Penalties = new(); + // zio: the reason and online-time bookkeeping for each penalty, kept beside Penalties so the + // tuple type the API exposes doesn't change. + private static readonly ConcurrentDictionary<(int Slot, PenaltyType Type, DateTime EndDateTime), (string? Reason, DateTime AddedAt, int MinutesLeft)> Details = + new(); + + /// + /// Adds a penalty and remembers its reason, for telling the player why they're penalized. + /// + /// Minutes left when added: the duration, less any already served + /// online when TimeMode is 0. + public static void AddPenalty(int slot, PenaltyType penaltyType, DateTime endDateTime, int durationInMinutes, string? reason, int? minutesLeft = null) + { + AddPenalty(slot, penaltyType, endDateTime, durationInMinutes); + Details[(slot, penaltyType, endDateTime)] = (reason, Time.ActualDateTime(), minutesLeft ?? durationInMinutes); + } + + /// + /// Returns how long the penalty IsPenalized found has left (null if permanent) and its reason. + /// + public static (TimeSpan? Remaining, string? Reason) GetPenaltyDetails(int slot, PenaltyType penaltyType, DateTime endDateTime) + { + var penalty = GetPlayerPenalties(slot, penaltyType).FirstOrDefault(p => p.EndDateTime == endDateTime); + var hasDetails = Details.TryGetValue((slot, penaltyType, endDateTime), out var details); + if (penalty.Duration == 0) + return (null, details.Reason); + + var now = Time.ActualDateTime(); + // TimeMode 0 only counts time online, and the player has been online since it was added. + var remaining = CS2_SimpleAdmin.Instance.Config.OtherSettings.TimeMode == 0 && hasDetails + ? TimeSpan.FromMinutes(details.MinutesLeft) - (now - details.AddedAt) + : endDateTime - now; + return (remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining, details.Reason); + } + + private static void RemoveDetails(Func<(int Slot, PenaltyType Type, DateTime EndDateTime), bool> match) + { + foreach (var key in Details.Keys.Where(match).ToList()) + Details.TryRemove(key, out _); + } + /// /// Adds a penalty for a specific player slot and penalty type. /// @@ -162,6 +202,7 @@ public static class PlayerPenaltyManager { Penalties.TryRemove(slot, out _); } + RemoveDetails(k => k.Slot == slot); } /// @@ -170,6 +211,7 @@ public static class PlayerPenaltyManager public static void RemoveAllPenalties() { Penalties.Clear(); + Details.Clear(); } /// @@ -184,6 +226,7 @@ public static class PlayerPenaltyManager { penaltyDict.Remove(penaltyType); } + RemoveDetails(k => k.Slot == slot && k.Type == penaltyType); } /// diff --git a/CS2-SimpleAdmin/lang/en.json b/CS2-SimpleAdmin/lang/en.json index f875b82..99ee96d 100644 --- a/CS2-SimpleAdmin/lang/en.json +++ b/CS2-SimpleAdmin/lang/en.json @@ -65,6 +65,11 @@ "sa_discord_penalty_unknown": "Unknown registered", "sa_player_penalty_chat_active": "{lightred}Your chat is blocked to: {grey}{0}", + "sa_player_penalty_gag_active": "{lightred}You are gagged for {grey}{0}{lightred}. Reason: {grey}{1}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_gag_active_perm": "{lightred}You are gagged permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_silence_active": "{lightred}You are silenced for {grey}{0}{lightred}. Reason: {grey}{1}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_silence_active_perm": "{lightred}You are silenced permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_no_reason": "none given", "sa_player_penalty_info_active_mute": "➔ Mute [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", diff --git a/README.md b/README.md index 2a73b8e..2ad3635 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ +# zio.sh fork + +This is zio.sh's fork of CS2-SimpleAdmin 1.8.2b (`upstream` is daffyyyy/CS2-SimpleAdmin, branched +from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code: + +- A gagged or silenced player can still send `@message` in team chat to reach online staff. +- The gag/silence chat message says how long is left (or that it's permanent) and the reason, in + place of "Your chat is blocked to: ". New lang keys `sa_player_penalty_gag_active`, + `..._gag_active_perm`, `..._silence_active`, `..._silence_active_perm` and + `sa_player_penalty_no_reason`, in `lang/en.json` only (other languages fall back to it). + +Publish with `FORGEJO_TOKEN=... ./release.sh v` (ModuleVersion is `1.8.2b-zioN`). +It rebuilds from the committed source and uploads `SimpleAdmin-.tar.gz` to git.zio.sh +(`cs2/SimpleAdmin`), holding only `CS2-SimpleAdmin.dll` and `lang/en.json`. The rest of the plugin +and `shared/CS2-SimpleAdminApi` stay as the upstream 1.8.2b release installed them. + +--- +

Build and Publish diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..c2b1e46 --- /dev/null +++ b/release.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Build our CS2-SimpleAdmin fork, package it as one tarball and publish it as a Forgejo release. +# +# ./release.sh e.g. ./release.sh v1.8.2b-zio1 +# +# Commit first: the plugin is rebuilt here from the committed source, so the release always matches +# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with +# the same tag replaces that release's attachment. +# +# Asset: SimpleAdmin-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf SimpleAdmin-.tar.gz -C /srv/cs2/game/csgo +# +# addons/counterstrikesharp/plugins/CS2-SimpleAdmin/ CS2-SimpleAdmin.dll, lang/en.json +# +# Only the files the fork changes. Everything else in the plugin folder (its libraries, other lang +# files, migrations) and shared/CS2-SimpleAdminApi stay as the upstream 1.8.2b release installed +# them; the fork doesn't touch the API. No configs/: those stay the server's. +set -euo pipefail + +FORGEJO_URL="https://git.zio.sh" +OWNER="cs2" +REPO="SimpleAdmin" +# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository. +# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token. +FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" + +# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj +# (ExcludeAssets=runtime); the server already provides it. +PLUGIN_FILES=( + CS2-SimpleAdmin.dll + lang/en.json +) + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + echo "usage: $0 (e.g. v1.8.2b-zio1)" >&2 + exit 1 +fi +if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then + echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + exit 1 +fi +for tool in podman jq curl git; do + command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; } +done + +cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" + +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2 + exit 1 +fi + +# The tag should name the version the plugin reports in `css_plugins list`. +version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' CS2-SimpleAdmin/CS2-SimpleAdmin.cs)" +if [[ "${TAG#v}" != "$version" ]]; then + echo "Tag $TAG doesn't match ModuleVersion $version in CS2-SimpleAdmin/CS2-SimpleAdmin.cs." >&2 + exit 1 +fi + +# --- Build ----------------------------------------------------------------------- +OUT="release-out" +PUBLISH="$OUT/publish" +rm -rf CS2-SimpleAdmin/bin CS2-SimpleAdmin/obj CS2-SimpleAdminApi/bin CS2-SimpleAdminApi/obj "$OUT" +# dotnet build, as upstream's CI does: the csproj sets PublishTrimmed, which a plugin mustn't be. +podman run --rm -v "$(pwd)":/src:Z -w /src \ + mcr.microsoft.com/dotnet/sdk:10.0 dotnet build CS2-SimpleAdmin/CS2-SimpleAdmin.csproj -c Release -o "/src/$PUBLISH" + +# --- Stage (layout of game/csgo/) and package ---------------------------------------- +STAGE="$OUT/stage" +PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/CS2-SimpleAdmin" +mkdir -p "$PLUGIN_DIR" +for file in "${PLUGIN_FILES[@]}"; do + mkdir -p "$PLUGIN_DIR/$(dirname "$file")" + cp "$PUBLISH/$file" "$PLUGIN_DIR/$file" +done + +TARBALL="SimpleAdmin-${TAG}.tar.gz" +tar -czf "$OUT/$TARBALL" -C "$STAGE" addons +rm -rf "$STAGE" "$PUBLISH" + +echo "Packaged:" +tar -tzf "$OUT/$TARBALL" + +# --- Tag --------------------------------------------------------------------------- +if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + git tag -a "$TAG" -m "$TAG" +fi +git push origin "refs/tags/$TAG" + +# --- Release ----------------------------------------------------------------------- +API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO" +AUTH=(-H "Authorization: token $FORGEJO_TOKEN") + +release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)" +if [[ -z "$release_json" ]]; then + body="$(git log -1 --format=%B "$TAG")" + release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \ + '{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' | + curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")" + echo "Created release $TAG" +else + echo "Release $TAG already exists, replacing its attachment" +fi +release_id="$(jq -r '.id' <<<"$release_json")" + +existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")" +if [[ -n "$existing_id" ]]; then + curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null +fi +curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \ + "$API/releases/$release_id/assets?name=$TARBALL" >/dev/null +echo "Uploaded $TARBALL" + +echo +echo "Download URL:" +echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"