using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Numerics; using System.Text.RegularExpressions; using Content.Server.Administration; using Content.Shared.Administration; using Robust.Shared.Console; using Robust.Shared.Maths; using Robust.Shared.Toolshed; using Robust.Shared.Toolshed.Errors; using Robust.Shared.Utility; namespace Content.Server._DV.Toolshed; /// /// An extended version of the engine's do command. Runs a console command once per piped value, /// substituting $NAME tokens in the command string first. /// /// /// On top of the built-in tokens that do supports ($ID, $PID, $WX, $WY, /// $LX, $LY, $SELF), any other $name is resolved as a toolshed variable and /// converted to a string. Tokens that don't resolve to anything are left untouched. /// /// Where the command actually runs is explicit: /// /// doext:client sends it back down to the calling user's own client to run locally. This is what /// you want for client-side commands like exec, which resolve paths against the client's user data. /// doext:server runs it server-side as the calling session, like do does. /// /// /// /// self doext:client "exec /Script_$var.txt" /// i 5 => $count; self doext:server "somecommand $count" /// [ToolshedCommand(Name = "doext"), AdminCommand(AdminFlags.Debug)] public sealed class DoExtCommand : ToolshedCommand { /// /// Matches a $name token. Names use the same character set as toolshed variable names, so this /// matches whole identifiers - $IDLE resolves as "IDLE" rather than being mangled into $ID. /// private static readonly Regex TokenRegex = new(@"\$(\w+)", RegexOptions.Compiled); [Dependency] private readonly IConsoleHost _console = default!; private SharedTransformSystem? _xform; /// /// Sends the command back down to the calling user's client, which runs it locally. /// [CommandImplementation("client"), TakesPipedTypeAsGeneric] public IEnumerable Client(IInvocationContext ctx, [PipedArgument] IEnumerable input, string command) { // No session means there's no client to hand this back to - e.g. the server console. if (ctx.Session is not { } session) { ctx.ReportError(new NoClientSessionError()); yield break; } foreach (var value in input) { _console.RemoteExecuteCommand(session, Substitute(command, value, ctx)); yield return value; } } /// /// Runs the command on the server as the calling session. /// [CommandImplementation("server"), TakesPipedTypeAsGeneric] public IEnumerable Server(IInvocationContext ctx, [PipedArgument] IEnumerable input, string command) { foreach (var value in input) { _console.ExecuteCommand(ctx.Session, Substitute(command, value, ctx)); yield return value; } } private string Substitute(string command, T value, IInvocationContext ctx) { return TokenRegex.Replace(command, match => { var name = match.Groups[1].Value; if (TryGetBuiltin(name, value, ctx, out var builtin)) return builtin; // Anything else is looked up as a toolshed variable and implicitly stringified. if (ctx.ReadVar(name) is { } variable) return Stringify(variable); // Unresolved, so leave the token alone rather than silently blanking it. return match.Value; }); } private bool TryGetBuiltin(string name, T value, IInvocationContext ctx, [NotNullWhen(true)] out string? result) { switch (name) { case "SELF": result = Stringify(value); return true; case "PID": result = (ctx.Session?.AttachedEntity ?? EntityUid.Invalid).ToString(); return true; } // The remaining tokens are all positional, so they only apply when piping entities. if (value is not EntityUid uid) { result = null; return false; } switch (name) { case "ID": result = uid.ToString(); return true; case "WX": result = Number(WorldPosition(uid).X); return true; case "WY": result = Number(WorldPosition(uid).Y); return true; case "LX": result = Number(Transform(uid).Coordinates.X); return true; case "LY": result = Number(Transform(uid).Coordinates.Y); return true; } result = null; return false; } private Vector2 WorldPosition(EntityUid uid) { _xform ??= GetSys(); return _xform.GetWorldPosition(uid); } private static string Number(float value) => value.ToString(CultureInfo.InvariantCulture); private static string Stringify(object? value) => value?.ToString() ?? string.Empty; } public sealed class NoClientSessionError : IConError { public FormattedMessage DescribeInner() { return FormattedMessage.FromUnformatted("There is no client to run this on. doext:client must be run by a player, not the server console."); } public string? Expression { get; set; } public Vector2i? IssueSpan { get; set; } public StackTrace? Trace { get; set; } }