Files
squad-rain-ops-mini/Plugins/TKAutoKickPlugin.cs

561 lines
21 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using RainOpsMini.Helpers;
using RainOpsMini.Helpers.RCON;
using RainOpsMini;
using RainOpsMini.Models;
namespace RainOpsMini.Plugins
{
/// <summary>
/// TK自动踢出插件
/// </summary>
public class TKAutoKickPlugin : IPlugin, IDisposable
{
/// <summary>
/// 插件名称
/// </summary>
public string Name => "TK自动踢出";
/// <summary>
/// 插件描述
/// </summary>
public string Description => "TK自动踢出插件 - 自动检测队友伤害并要求道歉,否则踢出";
/// <summary>
/// 插件分类
/// </summary>
public PluginCategory Category => PluginCategory.BasicFunction;
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled => _config.Enabled;
private TKAutoKickConfig _config = new TKAutoKickConfig();
private readonly object _lock = new object();
private HashSet<string> _adminSteamIds = new HashSet<string>();
private DateTime _lastAdminLoadTime = DateTime.MinValue;
// 存储待踢出的玩家任务KillerSteamId -> Context
private static readonly Dictionary<string, TkContext> _pendingKicks = new Dictionary<string, TkContext>();
/// <summary>
/// TK上下文信息用于追踪受害者和任务控制
/// </summary>
private class TkContext
{
public CancellationTokenSource Cts { get; set; } = new CancellationTokenSource();
public string VictimSteamId { get; set; } = "";
public string VictimName { get; set; } = "";
}
/// <summary>
/// 构造函数,初始化插件并订阅事件
/// </summary>
public TKAutoKickPlugin()
{
ApplyConfig();
}
/// <summary>
/// 获取当前配置
/// </summary>
/// <returns>配置对象</returns>
public object GetConfig() => _config;
/// <summary>
/// 更新配置
/// </summary>
/// <param name="config">JSON配置元素</param>
public void UpdateConfig(JsonElement config)
{
try
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
_config = JsonSerializer.Deserialize<TKAutoKickConfig>(config.GetRawText(), options) ?? new TKAutoKickConfig();
ApplyConfig();
}
catch (Exception ex)
{
RainOpsLog.Log($"[TKAutoKick] 配置更新失败: {ex.Message}");
}
}
/// <summary>
/// 应用配置,根据启用状态订阅或取消订阅事件
/// </summary>
private void ApplyConfig()
{
Program.OnRconMessageReceived -= OnRconMessage;
if (_config.Enabled)
{
Program.OnRconMessageReceived += OnRconMessage;
}
}
/// <summary>
/// 重置配置为默认值
/// </summary>
public void ResetConfig()
{
_config = new TKAutoKickConfig();
}
/// <summary>
/// 释放资源,取消订阅和待处理任务
/// </summary>
public void Dispose()
{
Program.OnRconMessageReceived -= OnRconMessage;
foreach (var ctx in _pendingKicks.Values)
{
ctx.Cts.Cancel();
ctx.Cts.Dispose();
}
_pendingKicks.Clear();
}
/// <summary>
/// 处理接收到的 RCON 消息
/// </summary>
/// <param name="msg">原始消息内容</param>
private void OnRconMessage(string msg)
{
if (!IsEnabled) return;
try
{
// 1. 检测 TK 消息
// 假设消息格式包含 "Team Killed"
// 尝试解析: "Player1 Team Killed Player2" 或 "LogSquad: Player: Name (ID) Team Killed: Name (ID)"
if (msg.Contains("Team Killed"))
{
HandleTeamKill(msg);
}
// 2. 检测道歉消息
// 使用 RconParser 解析聊天消息,或者自己解析
// 这里为了方便,直接尝试解析聊天格式
var chatMsg = RconParser.ParseChatMessage(msg);
if (chatMsg != null)
{
HandleChatMessage(chatMsg);
}
}
catch (Exception ex)
{
Console.WriteLine($"[TKAutoKick] 处理消息异常: {ex.Message}");
}
}
/// <summary>
/// 处理队友伤害事件
/// </summary>
/// <param name="msg">包含 TK 信息的日志消息</param>
private void HandleTeamKill(string msg)
{
// 用户指定的匹配格式
// 示例: [ChatAdmin] ASQKillDeathRuleset : Player Name1 Team Killed Player Name2
var regex = new Regex(@"\[ChatAdmin\] ASQKillDeathRuleset : Player (.*) Team Killed Player (.*)");
var match = regex.Match(msg);
string killerName = "";
string victimName = "";
if (match.Success)
{
killerName = match.Groups[1].Value.Trim();
victimName = match.Groups[2].Value.Trim();
}
else
{
// 保留一种备用的匹配方式,以防格式有变
// 尝试标准 Squad 日志格式 (带 SteamId 的)
var detailedMatch = Regex.Match(msg, @"Player:\s*(.+?)\s*\(SteamId:\s*(\d+)\)\s*Team Killed:\s*(.+?)\s*\(SteamId:\s*(\d+)\)");
if (detailedMatch.Success)
{
// ... 之前的逻辑,直接使用 ID
// 为保持逻辑统一,这里我们先提取名字,后面统一模糊匹配,或者如果这里能直接拿到 ID 更好
// 但为了简化代码结构,这里我们只提取名字,让后面统一处理,除非我们非常确信 ID 是对的。
// 鉴于 detailedMatch 是非常具体的,如果匹配成功,我们应该优先使用 ID。
var killerId = detailedMatch.Groups[2].Value;
var victimId = detailedMatch.Groups[4].Value;
var k = Program.RconCache.Players.FirstOrDefault(p => p.SteamId == killerId);
var v = Program.RconCache.Players.FirstOrDefault(p => p.SteamId == victimId);
if (k != null && v != null)
{
ProcessTk(k, v);
return;
}
}
return; // 如果都不匹配,则忽略
}
// 模糊匹配
var killer = FindPlayerFuzzy(killerName);
var victim = FindPlayerFuzzy(victimName);
if (killer != null && victim != null)
{
ProcessTk(killer, victim);
}
}
/// <summary>
/// 执行 TK 惩罚流程(警告 + 定时踢出)
/// </summary>
/// <param name="killer">施暴者信息</param>
/// <param name="victim">受害者信息</param>
private void ProcessTk(PlayerInfo killer, PlayerInfo victim)
{
// 排除自杀
if (killer.SteamId == victim.SteamId) return;
// 1. 被TK的人是管理或者白名单IsAdminOrWhiteList
if (IsAdminOrWhiteList(victim.SteamId)) return;
// 2. 被TK的人是机器人
if (IsBot(victim)) return;
// 3. TK的人是机器人
if (IsBot(killer)) return;
int maxSecondInt = 60;
int.TryParse(_config.MaxSecond, out maxSecondInt);
if (maxSecondInt <= 0) maxSecondInt = 60;
// 格式化命令
var victimWarnCmd = FormatCommand(_config.VictimWarnCommand, killer.Name, killer.SteamId, victim.Name, victim.SteamId, _config.MaxSecond);
var killerWarnCmd = FormatCommand(_config.KillerWarnCommand, killer.Name, killer.SteamId, victim.Name, victim.SteamId, _config.MaxSecond);
// 发送警告
// 发给 Victim (受害者)
if (!string.IsNullOrWhiteSpace(victimWarnCmd)) SendComd(victimWarnCmd);
// 发给 Killer (施暴者)
if (!string.IsNullOrWhiteSpace(killerWarnCmd)) SendComd(killerWarnCmd);
// 全服广播
if (_config.IsBroadcastEnabled)
{
var broadcastCmd = FormatCommand(_config.BroadcastCommand, killer.Name, killer.SteamId, victim.Name, victim.SteamId, _config.MaxSecond);
if (!string.IsNullOrWhiteSpace(broadcastCmd)) SendComd(broadcastCmd);
}
// 定时踢出逻辑
if (_config.MaxSecond != "0")
{
// 设置定时任务
ScheduleKick(killer, victim, maxSecondInt);
}
}
/// <summary>
/// 替换命令模板中的占位符
/// </summary>
private string FormatCommand(string template, string killerName, string killerSteamId, string victimName, string victimSteamId, string maxSecond)
{
if (string.IsNullOrEmpty(template)) return string.Empty;
return template
.Replace("{KillerName}", killerName)
.Replace("{KillerSteamId}", killerSteamId)
.Replace("{VictimName}", victimName)
.Replace("{VictimSteamId}", victimSteamId)
.Replace("{MaxSecond}", maxSecond);
}
/// <summary>
/// 加载管理员列表
/// </summary>
private void LoadAdmins()
{
try
{
var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SquadGame", "ServerConfig", "Admins.cfg");
if (File.Exists(path))
{
var lines = File.ReadAllLines(path);
_adminSteamIds.Clear();
foreach (var line in lines)
{
if (line.Trim().StartsWith("Admin="))
{
var parts = line.Split('=', ':');
if (parts.Length >= 2)
{
var steamId = parts[1].Trim();
if (!string.IsNullOrEmpty(steamId))
{
_adminSteamIds.Add(steamId);
}
}
}
}
_lastAdminLoadTime = DateTime.Now;
}
}
catch (Exception ex)
{
RainOpsLog.Log($"[TKAutoKick] Failed to load admins: {ex.Message}");
}
}
/// <summary>
/// 检查是否是管理员或白名单
/// </summary>
private bool IsAdminOrWhiteList(string steamId)
{
if (string.IsNullOrEmpty(steamId)) return false;
// 每5分钟重新加载一次
if ((DateTime.Now - _lastAdminLoadTime).TotalMinutes > 5)
{
LoadAdmins();
}
if (_adminSteamIds.Contains(steamId)) return true;
if (_config.WhitelistSteamIds != null && _config.WhitelistSteamIds.Contains(steamId)) return true;
return false;
}
/// <summary>
/// 检查是否是机器人
/// </summary>
private bool IsBot(PlayerInfo p)
{
if (p == null) return true;
if (string.IsNullOrEmpty(p.SteamId)) return true;
// 简单的 SteamID 校验,正常 SteamID 应该是 17 位数字
if (p.SteamId.Length < 10) return true;
return false;
}
/// <summary>
/// 根据名字模糊查找在线玩家
/// </summary>
/// <param name="namePart">玩家名字片段</param>
/// <returns>匹配到的玩家信息,未找到返回 null</returns>
private PlayerInfo? FindPlayerFuzzy(string namePart)
{
if (string.IsNullOrWhiteSpace(namePart)) return null;
var players = Program.RconCache.Players; // 已经是拷贝或引用,直接用
// 1. 精确匹配
var exact = players.FirstOrDefault(p => p.Name.Equals(namePart, StringComparison.OrdinalIgnoreCase));
if (exact != null) return exact;
// 2. 包含匹配
return players.FirstOrDefault(p => p.Name.Contains(namePart, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 处理玩家聊天消息(用于检测道歉)
/// </summary>
/// <param name="chatMsg">聊天消息对象</param>
private void HandleChatMessage(ChatMessage chatMsg)
{
// 检查是否是道歉关键词
if (_config.ApologyKeywords.Any(k => chatMsg.Message.Contains(k, StringComparison.OrdinalIgnoreCase)))
{
// 检查该玩家是否在待踢列表
lock (_lock)
{
if (_pendingKicks.TryGetValue(chatMsg.SteamId, out var ctx))
{
// 如果已经请求取消,说明正在处理或已处理
if (ctx.Cts.IsCancellationRequested) return;
// 取消踢出
ctx.Cts.Cancel();
// 通知施暴者
var successCmd = FormatCommand(_config.ApologySuccessCommand, chatMsg.Name, chatMsg.SteamId, ctx.VictimName, ctx.VictimSteamId, _config.MaxSecond);
if (!string.IsNullOrWhiteSpace(successCmd)) SendComd(successCmd);
// 通知受害者
if (!string.IsNullOrEmpty(ctx.VictimSteamId))
{
var notifyCmd = FormatCommand(_config.ApologyNotificationCommand, chatMsg.Name, chatMsg.SteamId, ctx.VictimName, ctx.VictimSteamId, _config.MaxSecond);
if (!string.IsNullOrWhiteSpace(notifyCmd)) SendComd(notifyCmd);
}
// 不在这里移除或释放,交给 Task 的 finally 块统一处理
// 这样可以避免 Dispose 竞态条件
}
}
}
}
/// <summary>
/// 调度踢出任务
/// </summary>
/// <param name="killer">施暴者</param>
/// <param name="victim">受害者</param>
/// <param name="seconds">延迟秒数</param>
private void ScheduleKick(PlayerInfo killer, PlayerInfo victim, int seconds)
{
lock (_lock)
{
if (_pendingKicks.ContainsKey(killer.SteamId))
{
var oldCtx = _pendingKicks[killer.SteamId];
oldCtx.Cts.Cancel();
oldCtx.Cts.Dispose();
_pendingKicks.Remove(killer.SteamId);
}
var ctx = new TkContext
{
VictimSteamId = victim.SteamId,
VictimName = victim.Name
};
_pendingKicks[killer.SteamId] = ctx;
// 启动任务
Task.Run(async () =>
{
try
{
await Task.Delay(seconds * 1000, ctx.Cts.Token);
if (!ctx.Cts.Token.IsCancellationRequested)
{
// 时间到,执行踢出
ExecuteKick(killer);
}
}
catch (TaskCanceledException)
{
// 任务被取消 (道歉了)
}
finally
{
// 清理
lock (_lock)
{
// 只有当 Map 里的还是这个 ctx 时才移除 (防止已被新任务覆盖)
if (_pendingKicks.TryGetValue(killer.SteamId, out var currentCtx) && currentCtx == ctx)
{
_pendingKicks.Remove(killer.SteamId);
ctx.Cts.Dispose();
}
}
}
});
}
}
/// <summary>
/// 执行踢出操作
/// </summary>
/// <param name="killer">要踢出的玩家信息</param>
private void ExecuteKick(PlayerInfo killer)
{
Console.WriteLine($"[TKAutoKick] 踢出玩家 {killer.Name} ({killer.SteamId})");
SendComd($"AdminKick {killer.SteamId} {killer.Name} {_config.KickReason}");
var msg = FormatCommand(_config.KickBroadcastCommand, killer.Name, killer.SteamId, "", "", _config.MaxSecond);
if (!string.IsNullOrWhiteSpace(msg)) SendComd(msg);
}
/// <summary>
/// 发送 RCON 命令
/// </summary>
/// <param name="cmd">命令内容</param>
private async void SendComd(string cmd)
{
if (Program.Client != null && Program.Client.IsConnected)
{
await Program.Client.SendCommandAsync(cmd);
}
}
}
/// <summary>
/// TK自动踢出插件配置类
/// </summary>
public class TKAutoKickConfig
{
/// <summary>
/// 是否启用插件
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// 道歉时限(秒)
/// </summary>
public string MaxSecond { get; set; } = "60";
/// <summary>
/// 道歉关键词列表
/// </summary>
public List<string> ApologyKeywords { get; set; } = new List<string> { "s", "sor", "sorry", "sry" };
/// <summary>
/// 白名单SteamID列表 (不被踢出,且作为受害者时不触发踢人)
/// </summary>
public List<string> WhitelistSteamIds { get; set; } = new List<string>();
/// <summary>
/// 发给受害者的警告命令模板
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
/// </summary>
public string VictimWarnCommand { get; set; } = "AdminWarn {VictimSteamId} 您被队友({KillerName})击倒了!";
/// <summary>
/// 发给施暴者的警告命令模板
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
/// </summary>
public string KillerWarnCommand { get; set; } = "AdminWarn {KillerSteamId} 您击倒了队友({VictimName})!请在{MaxSecond}s内道歉";
/// <summary>
/// 是否开启全服广播
/// </summary>
public bool IsBroadcastEnabled { get; set; } = true;
/// <summary>
/// 全服广播命令模板
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
/// </summary>
public string BroadcastCommand { get; set; } = "AdminBroadcast {KillerName} 痛击了队友({VictimName})请在{MaxSecond}秒内表达歉意!否则将被移出服务器!";
/// <summary>
/// 道歉成功给施暴者的命令模板
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
/// </summary>
public string ApologySuccessCommand { get; set; } = "AdminWarn {KillerSteamId} 道歉成功!";
/// <summary>
/// 道歉成功给受害者的命令模板
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
/// </summary>
public string ApologyNotificationCommand { get; set; } = "AdminWarn {VictimSteamId} 队友 {KillerName} 已向您道歉。";
/// <summary>
/// 踢出理由
/// </summary>
public string KickReason { get; set; } = "队友伤害未道歉";
/// <summary>
/// 踢出时的全服广播命令模板
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
/// </summary>
public string KickBroadcastCommand { get; set; } = "AdminBroadcast {KillerName} 因伤害队友未道歉被移出服务器!";
}
}