SecondBot/SecondBotEvents/Services/CommandsService.cs
Madpeter 008cb44e24 Use UnsafeRelaxed JSON serialization globally
Replaces all usages of JsonSerializer.Serialize with the UnsafeRelaxed option via a new JsonOptions.UnsafeRelaxed static property. This ensures consistent relaxed escaping for all JSON output, improving compatibility with consumers expecting unescaped JSON. Refactors affected command, service, and static classes to use the new option.
2025-10-05 20:07:42 +01:00

621 lines
23 KiB
C#

using Newtonsoft.Json.Linq;
using OpenMetaverse;
using OpenMetaverse.ImportExport.Collada14;
using SecondBotEvents.Config;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace SecondBotEvents.Services
{
public class BotCommandNotice(string command, string args, string source, bool accepted, string results)
{
public string command { get; set; } = command;
public string args { get; set; } = args;
public string source { get; set; } = source;
public bool accepted { get; set; } = accepted;
public string results { get; set; } = results;
}
public class CommandsService : BotServices
{
private EventHandler<BotCommandNotice> BotclientCommandEventNotices;
public event EventHandler<BotCommandNotice> BotclientCommandEventNotice
{
add { lock (BotCommandNoticeLockable) { BotclientCommandEventNotices += value; } }
remove { lock (BotCommandNoticeLockable) { BotclientCommandEventNotices -= value; } }
}
private readonly object BotCommandNoticeLockable = new();
public new CommandsConfig myConfig = null;
public bool acceptNewCommands = false;
public CommandsService(EventsSecondBot setMaster) : base(setMaster)
{
myConfig = new CommandsConfig(master.fromEnv, master.fromFolder);
if (myConfig.GetEnabled() == false)
{
return;
}
LoadCommands();
}
public bool GetAllowServiceCommands()
{
return myConfig.GetAllowServiceControl();
}
protected void LoadCommands()
{
Dictionary<string, Type> commandmodules = http_commands_helper.getCommandModules();
foreach (Type entry in commandmodules.Values)
{
LoadCommandEndpoint(entry);
}
}
protected virtual void LoadCommandEndpoint(Type endpointtype)
{
CommandsAPI controler = (CommandsAPI)Activator.CreateInstance(endpointtype, args: [master]);
commandEndpoints.Add(endpointtype.Name, controler);
foreach (MethodInfo M in endpointtype.GetMethods())
{
bool isCallable = false;
foreach (CustomAttributeData At in M.CustomAttributes)
{
if (At.AttributeType.Name == "About")
{
isCallable = true;
break;
}
}
if (isCallable == true)
{
if (endpointcommandmap.ContainsKey(M.Name) == true)
{
LogFormater.Warn("Namespace: " + endpointtype.Name + " / Command: " + M.Name + " already found in " + endpointcommandmap[M.Name]);
continue;
}
if (commandnameLowerToReal.ContainsKey(M.Name.ToLowerInvariant()) == true)
{
LogFormater.Warn("Namespace: " + endpointtype.Name + " / Command: " + M.Name + " already found in " + endpointcommandmap[M.Name]);
continue;
}
endpointcommandmap.Add(M.Name, endpointtype.Name);
commandnameLowerToReal.Add(M.Name.ToLowerInvariant(), M.Name);
}
}
}
public override void Start(bool updateEnabled = false, bool setEnabledTo = false)
{
running = true;
master.BotClientNoticeEvent += BotClientRestart;
}
public override void Stop()
{
running = false;
acceptNewCommands = false;
}
public override string Status()
{
if(myConfig == null)
{
return "No Config";
}
else if(myConfig.GetHideStatusOutput() == true)
{
return "hidden";
}
if (acceptNewCommands == false)
{
return "Disabled";
}
return "Enabled";
}
protected void BotImMessage(object o, InstantMessageEventArgs e)
{
if(acceptNewCommands == false)
{
return;
}
if (e.IM.FromAgentName == GetClient().Self.Name)
{
return;
}
if (myConfig.GetAllowIMcontrol() == false)
{
return;
}
bool acceptMessage = false;
bool requireSigning = true;
switch (e.IM.Dialog)
{
case InstantMessageDialog.MessageFromObject:
{
acceptMessage = true;
if(myConfig.GetObjectMasterOptout() == true)
{
string ownername = master.DataStoreService.GetAvatarName(e.IM.FromAgentID);
requireSigning = !myConfig.GetMastersCSV().Contains(ownername);
if ((requireSigning == false) && (myConfig.GetCheckDotNames() == true))
{
string[] bits = e.IM.FromAgentName.ToLower().Split('.');
if (bits.Length == 2)
{
requireSigning = myConfig.GetMastersCSV().Contains(bits[0].FirstCharToUpper() + " " + bits[1].FirstCharToUpper());
}
}
}
break;
}
case InstantMessageDialog.MessageFromAgent: // shared with SessionSend
case InstantMessageDialog.SessionSend:
{
if(e.IM.GroupIM == true) // groups are unable to issue commands
{
break;
}
if (myConfig.GetEnableMasterControls() == false)
{
break;
}
master.DataStoreService.AddAvatar(e.IM.FromAgentID, e.IM.FromAgentName);
requireSigning = false;
acceptMessage = myConfig.GetMastersCSV().Contains(e.IM.FromAgentName);
if ((acceptMessage == false) && (myConfig.GetCheckDotNames() == true))
{
string[] bits = e.IM.FromAgentName.ToLower().Split('.');
if (bits.Length == 2)
{
acceptMessage = myConfig.GetMastersCSV().Contains(bits[0].FirstCharToUpper()+" " + bits[1].FirstCharToUpper());
}
}
break;
}
default:
{
break;
}
}
if(acceptMessage == false)
{
return;
}
CommandInterfaceCaller(e.IM.Message, requireSigning);
}
public bool AvatarUUIDIsMaster(UUID avatar)
{
foreach(string name in myConfig.GetMastersCSV())
{
string find = master.DataStoreService.GetAvatarUUID(name);
if (find != "lookup")
{
if(avatar.ToString() == find)
{
return true;
}
}
}
return false;
}
public KeyValuePair<bool, string> CommandInterfaceCaller(string message, bool requireSigning=false, bool viaCustomCommand=false, string forceSetSource= "command interface")
{
string source = forceSetSource;
if(viaCustomCommand == true)
{
source = "customcommand";
}
SignedCommand C = new(this,source,message,requireSigning,myConfig.GetEnforceTimeWindow(),myConfig.GetTimeWindowSecs(),myConfig.GetSharedSecret());
if (C.accepted == false)
{
return new KeyValuePair<bool, string>(false, "Not accepted via signing");
}
KeyValuePair<bool, string> reply = RunCommand(source, C, viaCustomCommand);
return reply;
}
public void SmartCommandReply(string target, string output, string command)
{
string mode = "CHAT";
UUID target_avatar = UUID.Zero;
int target_channel = 0;
if (target.StartsWith("http://"))
{
mode = "HTTP";
}
else if (UUID.TryParse(target, out target_avatar) == true)
{
mode = "IM";
}
else if (target.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length == 2)
{
string UUIDfetch = master.DataStoreService.GetAvatarUUID(target);
if (UUIDfetch == "lookup")
{
return;
}
mode = "IM";
if(UUID.TryParse(UUIDfetch, out target_avatar) == false)
{
return;
}
}
else if (new EmailAddressAttribute().IsValid(target) == true)
{
mode = "EMAIL";
}
else if (int.TryParse(target, out target_channel) == false)
{
return;
}
if (mode == "CHAT")
{
if (target_channel < 0)
{
LogFormater.Crit("[SmartReply] output Channel must be zero or higher - using 99");
target_channel = 999;
}
GetClient().Self.Chat(output, target_channel, ChatType.Normal);
}
else if (mode == "IM")
{
GetClient().Self.InstantMessage(target_avatar, output);
}
else if (mode == "HTTP")
{
Dictionary<string, string> values = new()
{
{ "reply", output },
{ "command", command },
};
var content = new FormUrlEncodedContent(values);
try
{
HTTPclient.PostAsync(target, content);
}
catch (Exception e)
{
LogFormater.Crit("[SmartReply] HTTP failed: " + e.Message + "");
}
}
else if(mode == "EMAIL")
{
KeyValuePair<bool,string> reply = master.SmtpService.sendReplyTarget(target,command,output);
if(reply.Key == false)
{
LogFormater.Crit("[SmartReply] EMAIL failed: " + reply.Value);
}
}
}
protected HttpClient HTTPclient = new();
protected KeyValuePair<bool, string> RunBaseCommand(SignedCommand C, string source)
{
try
{
CommandsAPI Endpoint = commandEndpoints[endpointcommandmap[C.command]];
MethodInfo theMethod = Endpoint.GetType().GetMethod(C.command);
if (theMethod != null)
{
List<string> argsList = [.. C.args];
string reply = "incorrect number of args expected: " + theMethod.GetParameters().Length.ToString() + " but got: " + argsList.Count.ToString();
bool status = false;
if (argsList.Count == theMethod.GetParameters().Length)
{
status = true;
try
{
if (Endpoint == null)
{
return new KeyValuePair<bool, string>(false, "Endpoint is null");
}
object[] argsWorker = [.. argsList];
object processed = theMethod.Invoke(Endpoint, argsWorker);
reply = "Error";
if (processed != null)
{
reply = JsonSerializer.Serialize(processed, JsonOptions.UnsafeRelaxed);
}
CommandNotice(C.command, source, String.Join("@@@", C.args), true, reply);
return new KeyValuePair<bool, string>(status, reply);
}
catch (Exception e)
{
CommandNotice(C.command, source+" ~~ Failure: "+e.Message, String.Join("@@@", C.args), false, e.Message);
return new KeyValuePair<bool, string>(false, e.Message);
}
}
CommandNotice(C.command, source + " ~~ Failure: incorrect number of args expected: "+ theMethod.GetParameters().Length.ToString()+" but got "+ argsList.Count.ToString()+"", String.Join("@@@", C.args), false, "no results");
return new KeyValuePair<bool, string>(status, reply);
}
return new KeyValuePair<bool, string>(false, "theMethod is null");
}
catch (Exception e)
{
return new KeyValuePair<bool, string>(false, e.Message);
}
}
public KeyValuePair<bool, string> RunCommand(string source, SignedCommand C, bool inCustomCommand=false)
{
if(acceptNewCommands == false)
{
return new KeyValuePair<bool, string>(false, "Not accepting commands");
}
try
{
string lowerName = C.command.ToLower();
if(commandnameLowerToReal.ContainsKey(lowerName) == true)
{
C.command = commandnameLowerToReal[lowerName];
}
else if(master.CustomCommandsService.HasCommand(C.command) == true)
{
if(inCustomCommand == true)
{
return new KeyValuePair<bool, string>(false, "Custom command chaining is not allowed");
}
return master.CustomCommandsService.RunCommand(C, source);
}
else
{
return new KeyValuePair<bool, string>(false, "Unknown command");
}
KeyValuePair<bool,string> reply = RunBaseCommand(C, source);
if (C.replyTarget != null)
{
SmartCommandReply(C.replyTarget, reply.Value, C.command);
}
return reply;
}
catch (Exception e)
{
return new KeyValuePair<bool, string>(false, e.Message);
}
}
public string[] GetFullListOfCommands()
{
List<string> output = [];
foreach (string A in endpointcommandmap.Keys)
{
output.Add(A.ToLowerInvariant());
}
return [.. output];
}
protected Dictionary<string, CommandsAPI> commandEndpoints = [];
protected Dictionary<string, string> endpointcommandmap = []; // command = endpoint
protected Dictionary<string, string> commandnameLowerToReal = [];
protected void BotClientRestart(object o, BotClientNotice e)
{
if (e.isStart == false)
{
return;
}
acceptNewCommands = false;
LogFormater.Info("Commands service [Attached to new client]");
if (e.isDC == false)
{
GetClient().Network.LoggedOut += BotLoggedOut;
GetClient().Network.SimConnected += BotLoggedIn;
}
}
protected void BotLoggedOut(object o, LoggedOutEventArgs e)
{
GetClient().Network.SimConnected += BotLoggedIn;
LogFormater.Info("Commands service [Waiting for connect]");
}
protected void BotLoggedIn(object o, SimConnectedEventArgs e)
{
GetClient().Network.SimConnected -= BotLoggedIn;
GetClient().Self.IM += BotImMessage;
if (myConfig.GetEnableMasterControls() == true)
{
foreach(string A in myConfig.GetMastersCSV())
{
master.DataStoreService.KnownAvatar(A);
}
}
acceptNewCommands = true;
LogFormater.Info("Commands service [accepting IM commands]");
}
public void CommandNotice(string command,string source, string args,bool accepted, string results)
{
BotCommandNotice e = new(command, args, source, accepted, results);
EventHandler<BotCommandNotice> handler = BotclientCommandEventNotices;
handler?.Invoke(this, e);
if (master.BotClient.basicCfg.GetLogCommands() == true)
{
if (myConfig.GetCommandHistoryLogResults() == false)
{
e.results = "~hidden~";
}
LogFormater.Info("Command log:" + JsonSerializer.Serialize(e, JsonOptions.UnsafeRelaxed));
}
}
}
public class SignedCommand
{
public string command;
public string signingCode;
public string[] args;
public string replyTarget = null;
public bool accepted = false;
public long unixtimeOfCommand = 0;
protected CommandsService master;
public SignedCommand(CommandsService setMaster, string source, string input, bool requireSigning, bool requireTimewindow, int windowSize, string secret)
{
master = setMaster;
UnpackInput(input);
if (requireSigning == false)
{
accepted = true; // just accept the command
return;
}
Vaildate(requireTimewindow, windowSize, secret);
}
public SignedCommand(CommandsService setMaster, string source, string setCommand, string setSigningCode, string[] setArgs,
int setUnixtime, string setReplyTarget, bool requireTimewindow, int windowSize, string secret, bool requireSigning = true)
{
master = setMaster;
command = setCommand;
signingCode = setSigningCode;
args = setArgs;
unixtimeOfCommand = setUnixtime;
replyTarget = setReplyTarget;
if (requireSigning == false)
{
accepted = true; // just accept the command
return;
}
Vaildate(requireTimewindow, windowSize, secret);
}
protected void Vaildate(bool requireTimewindow, int windowSize, string secret)
{
accepted = false;
long dif = SecondbotHelpers.UnixTimeNow() - unixtimeOfCommand;
if(requireTimewindow == true)
{
if ((unixtimeOfCommand == 0) || (dif > windowSize))
{
return;
}
}
if (signingCode == null) // no signing code
{
return;
}
string raw = command;
raw += string.Join("~#~", args);
if (requireTimewindow == true)
{
raw += unixtimeOfCommand.ToString();
}
raw += secret;
string cooked = SecondbotHelpers.GetSHA1(raw);
if (cooked != signingCode)
{
return; // invaild signing code
}
accepted = true;
}
protected void UnpackInput(string input)
{
if (IsValidJson(input) == false)
{
UnpackStringToClass(input);
return;
}
UnpackJsonToClass();
}
protected JToken obj = null;
protected void UnpackStringToClass(string input)
{
// command|||args~#~args~#~args#|#reply_target@@@sha1 [inc time if required];|;unixtime of command
string[] bits = input.Split(";|;");
if(bits.Length == 2)
{
unixtimeOfCommand = Convert.ToInt32(bits[1]);
}
// command|||args~#~args~#~args#|#reply_target@@@sha1 [inc time if required]
// unixtime of command
bits = bits[0].Split("@@@");
if(bits.Length == 2)
{
signingCode = bits[1];
}
// command|||args~#~args~#~args#|#reply_target
// sha1 [inc time if required]
bits = bits[0].Split("#|#");
if(bits.Length == 2)
{
replyTarget = bits[1];
}
// command|||args~#~args~#~args
// reply_target
bits = bits[0].Split("|||");
command = bits[0];
// command
// args~#~args~#~args
args = [];
if(bits.Length == 2)
{
args = bits[1].Split("~#~");
}
}
protected void UnpackJsonToClass()
{
command = JsonHelper.GetValue<string>(obj, "cmd", null);
args = JsonHelper.GetValue<string[]>(obj, "args", null);
signingCode = JsonHelper.GetValue<string>(obj, "signing", null);
replyTarget = JsonHelper.GetValue<string>(obj, "reply", null);
unixtimeOfCommand = JsonHelper.GetValue<long>(obj, "unixtime", 0);
}
private bool IsValidJson(string strInput)
{
if (string.IsNullOrWhiteSpace(strInput)) { return false; }
strInput = strInput.Trim();
if ((strInput.StartsWith("{") && strInput.EndsWith("}")) || //For object
(strInput.StartsWith("[") && strInput.EndsWith("]"))) //For array
{
try
{
obj = JToken.Parse(strInput);
return true;
}
catch
{
return false;
}
}
return false;
}
}
public static class JsonHelper
{
public static T GetValue<T>(this JToken jToken, string key, T defaultValue = default)
{
dynamic ret = jToken[key];
if (ret == null) return defaultValue;
if (ret is JObject) return JsonSerializer.Deserialize<T>(ret.ToString());
return (T)ret;
}
}
public static class JsonOptions
{
public static readonly JsonSerializerOptions UnsafeRelaxed = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
}
}