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"