zio fork: @ staff chat while gagged, gag message with time left and reason
Some checks failed
Build and Publish / build (push) Has been cancelled
Build and Publish / publish (push) Has been cancelled

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.
This commit is contained in:
Astra 2026-09-27 19:07:06 +01:00
parent cf284bd121
commit 6171162ae9
10 changed files with 230 additions and 11 deletions

2
.gitignore vendored
View file

@ -12,3 +12,5 @@ CS2-SimpleAdmin_BanSoundModule — kopia
CLAUDE.md CLAUDE.md
/Modules/CS2-SimpleAdmin_BanSoundModule /Modules/CS2-SimpleAdmin_BanSoundModule
/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN /Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN
release-out/

View file

@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)");
public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
public override string ModuleAuthor => "daffyy"; 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) public override void Load(bool hotReload)
{ {

View file

@ -19,7 +19,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.369"> <PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
<PrivateAssets>none</PrivateAssets> <PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets> <ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -89,7 +89,7 @@ public partial class CS2_SimpleAdmin
}); });
// Add penalty to the player's penalty manager // 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) // Determine message keys and arguments based on gag time (permanent or timed)
var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0
@ -398,7 +398,7 @@ public partial class CS2_SimpleAdmin
}); });
// Add penalty to the player's penalty manager // 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) // Determine message keys and arguments based on mute time (permanent or timed)
var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0
@ -709,7 +709,7 @@ public partial class CS2_SimpleAdmin
}); });
// Add penalty to the player's penalty manager // 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; player.VoiceFlags = VoiceFlags.Muted;
// Determine message keys and arguments based on silence time (permanent or timed) // Determine message keys and arguments based on silence time (permanent or timed)

View file

@ -347,13 +347,15 @@ public partial class CS2_SimpleAdmin
return HookResult.Continue; 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 (!Config.OtherSettings.UserMessageGagChatType)
// { // {
if (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) || if (!staffChat && (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) ||
PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime)) PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime)))
{ {
if (_localizer != null && endDateTime is not null) 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; return HookResult.Stop;
} }
// } // }
@ -388,6 +390,36 @@ public partial class CS2_SimpleAdmin
return HookResult.Stop; 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<string>();
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) /*public HookResult OnCommandSay(CCSPlayerController? player, CommandInfo info)
{ {
if (player == null || !player.IsValid || player.IsBot) if (player == null || !player.IsValid || player.IsBot)

View file

@ -125,20 +125,22 @@ internal class PlayerManager
string muteType = mute.type; string muteType = mute.type;
DateTime ends = mute.ends; DateTime ends = mute.ends;
int duration = mute.duration; int duration = mute.duration;
string? reason = mute.reason;
int passed = mute.passed is null ? 0 : (int)mute.passed;
switch (muteType) switch (muteType)
{ {
// Apply mute penalty based on mute type // Apply mute penalty based on mute type
case "GAG": case "GAG":
PlayerPenaltyManager.AddPenalty( PlayerPenaltyManager.AddPenalty(
CS2_SimpleAdmin.PlayersInfo[steamId].Slot, CS2_SimpleAdmin.PlayersInfo[steamId].Slot,
PenaltyType.Gag, ends, duration); PenaltyType.Gag, ends, duration, reason, duration - passed);
// if (CS2_SimpleAdmin._localizer != null) // if (CS2_SimpleAdmin._localizer != null)
// mutesList[PenaltyType.Gag].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_gag", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]); // mutesList[PenaltyType.Gag].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_gag", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]);
break; break;
case "MUTE": case "MUTE":
PlayerPenaltyManager.AddPenalty( PlayerPenaltyManager.AddPenalty(
CS2_SimpleAdmin.PlayersInfo[steamId].Slot, CS2_SimpleAdmin.PlayersInfo[steamId].Slot,
PenaltyType.Mute, ends, duration); PenaltyType.Mute, ends, duration, reason, duration - passed);
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
player.VoiceFlags = VoiceFlags.Muted; player.VoiceFlags = VoiceFlags.Muted;
@ -149,7 +151,7 @@ internal class PlayerManager
default: default:
PlayerPenaltyManager.AddPenalty( PlayerPenaltyManager.AddPenalty(
CS2_SimpleAdmin.PlayersInfo[steamId].Slot, CS2_SimpleAdmin.PlayersInfo[steamId].Slot,
PenaltyType.Silence, ends, duration); PenaltyType.Silence, ends, duration, reason, duration - passed);
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
player.VoiceFlags = VoiceFlags.Muted; player.VoiceFlags = VoiceFlags.Muted;

View file

@ -8,6 +8,46 @@ public static class PlayerPenaltyManager
private static readonly ConcurrentDictionary<int, Dictionary<PenaltyType, List<(DateTime EndDateTime, int Duration, bool Passed)>>> Penalties = private static readonly ConcurrentDictionary<int, Dictionary<PenaltyType, List<(DateTime EndDateTime, int Duration, bool Passed)>>> Penalties =
new(); 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();
/// <summary>
/// Adds a penalty and remembers its reason, for telling the player why they're penalized.
/// </summary>
/// <param name="minutesLeft">Minutes left when added: the duration, less any already served
/// online when TimeMode is 0.</param>
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);
}
/// <summary>
/// Returns how long the penalty IsPenalized found has left (null if permanent) and its reason.
/// </summary>
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 _);
}
/// <summary> /// <summary>
/// Adds a penalty for a specific player slot and penalty type. /// Adds a penalty for a specific player slot and penalty type.
/// </summary> /// </summary>
@ -162,6 +202,7 @@ public static class PlayerPenaltyManager
{ {
Penalties.TryRemove(slot, out _); Penalties.TryRemove(slot, out _);
} }
RemoveDetails(k => k.Slot == slot);
} }
/// <summary> /// <summary>
@ -170,6 +211,7 @@ public static class PlayerPenaltyManager
public static void RemoveAllPenalties() public static void RemoveAllPenalties()
{ {
Penalties.Clear(); Penalties.Clear();
Details.Clear();
} }
/// <summary> /// <summary>
@ -184,6 +226,7 @@ public static class PlayerPenaltyManager
{ {
penaltyDict.Remove(penaltyType); penaltyDict.Remove(penaltyType);
} }
RemoveDetails(k => k.Slot == slot && k.Type == penaltyType);
} }
/// <summary> /// <summary>

View file

@ -65,6 +65,11 @@
"sa_discord_penalty_unknown": "Unknown registered", "sa_discord_penalty_unknown": "Unknown registered",
"sa_player_penalty_chat_active": "{lightred}Your chat is blocked to: {grey}{0}", "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_mute": "➔ Mute [{lightred}❌{default}] - Expire [{lightred}{0}{default}]",
"sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]",

View file

@ -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: <end date>". 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>` (ModuleVersion is `1.8.2b-zioN`).
It rebuilds from the committed source and uploads `SimpleAdmin-<tag>.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.
---
<p align="center"> <p align="center">
<a href="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml"> <a href="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml">
<img src="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml/badge.svg" alt="Build and Publish" /> <img src="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml/badge.svg" alt="Build and Publish" />

117
release.sh Executable file
View file

@ -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 <tag> 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-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
# tar -xzf SimpleAdmin-<tag>.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 <tag> (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"