diff --git a/Command.cs b/Command.cs new file mode 100644 index 0000000..b9b3c48 --- /dev/null +++ b/Command.cs @@ -0,0 +1,146 @@ +using Godot; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; + +public partial class Command : GodotObject +{ + public string User; + public bool IsStreamer; + public bool IsModerator; + // public CommandType Type; + private string _Args; + public CommandArguments GetArgs() + => new(_Args); + public Command(string user, bool isStreamer, bool isModerator, + // CommandType type, + string args) + { + User = user; + IsStreamer = isStreamer; + IsModerator = isModerator; + // Type = type; + _Args = args; + } +} + +/// +/// Manages a list of whitespace-delimited arguments. Does not +/// respect quotes or escape characters. A single argument containing +/// whitespace can be placed at the end and retrieved with +/// after the previous arguments are popped. +/// +/// +/// Use to safely handle subcommands. +/// +public class CommandArguments +{ + private static readonly Regex _Regex = new(@"\s+"); + private readonly string OriginalArgs; + private string Args; + public CommandArguments(string args) + { + OriginalArgs = args; + Args = args; + } + public string Pop() + { + var spl = _Regex.Split(Args, 2); + Args = spl.ElementAtOrDefault(1) ?? ""; + return spl[0]; + } + public string Remaining() + => Args; + public void Reset() + { + Args = OriginalArgs; + } + /// + /// Enumerates arguments from current state, without changing state + /// + /// + public IEnumerable Enumerate() + { + var a = DuplicateAtState(); + while (true) + { + var s = a.Pop(); + if (string.IsNullOrWhiteSpace(s)) + break; + yield return s; + } + } + /// + /// Creates a new arguments object that returns to this state when reset. + /// Any arguments that have already been popped will not return. + /// + public CommandArguments DuplicateAtState() + { + return new(Args); + } +} + +public enum CommandType +{ + MoveCard, + MoveRow, + DeleteCard, + DeleteRow, + CreateCard, + CreateRow, + RenameCard, + RenameRow, + RecolorRow, + ChangeCardImage, +} + +public static class CommandTypeHelper +{ + public static CommandType? ParseCommand(string commandType) + { + // if (Enum.TryParse(typeof(CommandType), commandType, true, out object ct)) + // return (CommandType)ct; + // return null; + var c = commandType.ToLower(); + if (c == "movecard") + { + return CommandType.MoveCard; + } + else if (c == "moverow") + { + return CommandType.MoveRow; + } + else if (c == "deletecard") + { + return CommandType.DeleteCard; + } + else if (c == "createcard") + { + return CommandType.CreateCard; + } + else if (c == "createrow") + { + return CommandType.CreateRow; + } + else if (c == "renamecard") + { + return CommandType.RenameCard; + } + else if (c == "renamerow") + { + return CommandType.RenameRow; + } + else if (c == "recolorrow") + { + return CommandType.RecolorRow; + } + else if (c == "changecardimage") + { + return CommandType.ChangeCardImage; + } + return null; + } +} \ No newline at end of file diff --git a/CommandHandler.cs b/CommandHandler.cs new file mode 100644 index 0000000..8a5f45d --- /dev/null +++ b/CommandHandler.cs @@ -0,0 +1,202 @@ +using Godot; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +public partial class CommandHandler : Node +{ + private Settings Settings { get; set; } + private game Game { get; set; } + private readonly List TaskQueue = new(); + private System.Net.Http.HttpClient _Client = null; + private System.Net.Http.HttpClient Client + { get + { + _Client ??= new(); + return _Client; + } + } + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + Settings = GetNode("/root/Settings"); + Game = GetNode("/root/Game"); + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + //delete/cleanup one finished task per frame + //currently just logs errors + var t = TaskQueue.FirstOrDefault(t => t.IsCompleted, null); + if (t is not null) + { + if (t.Exception is not null) + GD.PrintErr(t.Exception); + TaskQueue.Remove(t); + } + } + + private void IncomingCommand(Command command) + { + if (!Settings.IsUserAuthorized(command.User, command.IsStreamer, + command.IsModerator)) + return; + var baseArgs = command.GetArgs(); + var type = CommandTypeHelper.ParseCommand(baseArgs.Pop()); + var args = baseArgs.DuplicateAtState(); + switch (type) + { + case CommandType.MoveCard: + MoveCard(args); + break; + case CommandType.MoveRow: + MoveRow(args); + break; + case CommandType.DeleteCard: + DeleteCards(args); + break; + case CommandType.DeleteRow: + DeleteRow(args); + break; + case CommandType.CreateCard: + TaskQueue.Add(Task.Run(() => CreateCard(args))); + break; + case CommandType.CreateRow: + CreateRow(args); + break; + case CommandType.RenameCard: + RenameCard(args); + break; + case CommandType.RenameRow: + RenameRow(args); + break; + case CommandType.RecolorRow: + RecolorRow(args); + break; + case CommandType.ChangeCardImage: + TaskQueue.Add(Task.Run(() => ChangeCardImage(args))); + break; + default: + throw new Exception(); + } + } + + private void MoveCard(CommandArguments args) + { + var cardId = args.Pop(); + var rowId = args.Pop(); + var indexStr = args.Pop(); + if (int.TryParse(indexStr, out int index)) + Game.MoveCard(cardId, rowId, index); + else + Game.MoveCard(cardId, rowId, null); + } + private void MoveRow(CommandArguments args) + { + var rowId = args.Pop(); + var newIndexStr = args.Pop(); + var newIndex = int.Parse(newIndexStr); + Game.MoveRow(rowId, newIndex); + } + private void DeleteCards(CommandArguments args) + { + var ids = args.Enumerate().ToArray(); + Game.DeleteCards(ids); + } + private void DeleteRow(CommandArguments args) + { + var rowId = args.Pop(); + var deleteCards = args.Pop(); + if (bool.TryParse(deleteCards, out bool del)) + Game.DeleteRow(rowId, del); + else + Game.DeleteRow(rowId); + } + private async Task CreateCard(CommandArguments args) + { + var title = args.Pop(); + var url = args.Pop(); + Image img = await ImageFromUrl(url); + Game.CreateCard(title, img); + } + private void CreateRow(CommandArguments args) + { + var colorStr = args.Pop(); + var title = args.Remaining(); + var color = Color.FromString(colorStr, new Color(0, 0, 0, 0)); + if (color == new Color(0, 0, 0, 0)) + Game.CreateRow(null, title); + else + Game.CreateRow(color, title); + } + private void RenameCard(CommandArguments args) + { + var cardId = args.Pop(); + var title = args.Remaining(); + Game.RenameCard(cardId, title); + } + private void RenameRow(CommandArguments args) + { + var rowId = args.Pop(); + var title = args.Remaining(); + Game.RenameRow(rowId, title); + } + private void RecolorRow(CommandArguments args) + { + var rowId = args.Pop(); + var colorStr = args.Pop(); + var color = Color.FromString(colorStr, new Color(0, 0, 0, 0)); + if (color == new Color(0, 0, 0, 0)) + throw new Exception($"invalid color {colorStr}"); + Game.RecolorRow(rowId, color); + } + private async Task ChangeCardImage(CommandArguments args) + { + var cardId = args.Pop(); + var img = await ImageFromUrl(args.Pop()); + Game.ChangeCardImage(cardId, img); + } + private async Task ImageFromUrl(string url) + { + var uri = new Uri(url); + var resp = await Client.GetAsync(uri); + if (!resp.IsSuccessStatusCode) + return null; + Image img = new(); + var arr = await resp.Content.ReadAsByteArrayAsync(); + //TODO detect images by header rather than extension + switch (Path.GetExtension(uri.AbsolutePath).ToLower()) + { + case "png": + img.LoadPngFromBuffer(arr); + break; + case "jpg": + case "jpeg": + img.LoadJpgFromBuffer(arr); + break; + case "svg": + img.LoadSvgFromBuffer(arr); + break; + case "webp": + img.LoadWebpFromBuffer(arr); + break; + default: + throw new Exception("unrecognized filetype"); + } + return img; + } + protected override void Dispose(bool disposing) + { + if (disposing) + { + _Client?.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/CommandParser.cs b/CommandParser.cs new file mode 100644 index 0000000..8e10921 --- /dev/null +++ b/CommandParser.cs @@ -0,0 +1,44 @@ +// using System.Collections.Generic; + +// public static class CommandParser +// { +// public static List SplitString(string s) +// { +// List l = new(); +// bool quoted = false; +// bool escaped = false; +// bool trimming = true; +// string current = ""; +// foreach (char c in s) +// { +// switch (c) +// { +// case '\\': +// if (escaped) +// current += '\\'; +// escaped = !escaped; +// break; +// case '"': +// if (escaped) +// { +// current += '"'; +// escaped = false; +// } +// else +// { +// if (quoted) +// { +// quoted = false; + +// } +// else +// { + +// } +// } +// break; +// case +// } +// } +// } +// } \ No newline at end of file diff --git a/GUI-only.build b/GUI-only.build new file mode 100644 index 0000000..041748d --- /dev/null +++ b/GUI-only.build @@ -0,0 +1,13 @@ +{ + "disabled_build_options": { + "disable_2d_physics": true, + "disable_3d": true, + "disable_3d_physics": true + }, + "disabled_classes": [ + "Control", + "Node2D", + "Node3D" + ], + "type": "build_profile" +} \ No newline at end of file diff --git a/PrivMsg.cs b/PrivMsg.cs new file mode 100644 index 0000000..773533a --- /dev/null +++ b/PrivMsg.cs @@ -0,0 +1,256 @@ +using Godot; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +public class Privmsg : TwitchChatMessage +{ + /// + /// Contains metadata related to the chat badges in the badges tag. + /// According to Twitch's documentation this should only include info about + /// subscription length, but it also contains prediction info and who knows what else. + /// + public IEnumerable BadgeInfo => TryGetTag("badge-info").Split(','); + /// + /// Contains the total number of months the user has subscribed, even if they aren't + /// subscribed currently. + /// + public int SubscriptionLength + { get + { + //TODO redo this, functional style clearly didn't work here + if (int.TryParse((BadgeInfo.FirstOrDefault( + b => b.StartsWith("SUBSCRIBER", StringComparison.CurrentCultureIgnoreCase)) ?? "") + .Split("/", 2).ElementAtOrDefault(1) ?? "", out int value)) + return value; + return 0; + } + } + // /// + // /// List of chat badges. Most badges have only 1 version, but some badges like + // /// subscriber badges offer different versions of the badge depending on how + // /// long the user has subscribed. To get the badge, use the Get Global Chat + // /// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s + // /// value in the response. Then, match the version to the id field in the list of versions. + // /// + // public List Badges + // { get + // { + // if (!MessageTags.TryGetValue("badges", out string? value)) + // return []; + // if (value == null) + // return []; + // List badges = []; + // foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries)) + // { + // var spl = item.Split('/', 2); + // badges.Add(new Badge(spl[0], spl[1])); + // } + // return badges; + // } + // } + /// + /// The amount of bits cheered. Equals 0 if message did not contain a cheer. + /// + public int Bits + { get + { + if (!MessageTags.TryGetValue("bits", out string value)) + return 0; + if (!int.TryParse(value, out int bits)) + return 0; + return bits; + } + } + /// + /// The color of the user’s name in the chat room. This is a hexadecimal + /// RGB color code in the form, #. This tag may be empty if it is never set. + /// + public Color? Color + { get + { + //should have format "#RRGGBB" + if (!MessageTags.TryGetValue("color", out string value)) + return null; + if (value.Length < 7) + return null; + int r = Convert.ToInt32(value.Substring(1, 2), 16); + int g = Convert.ToInt32(value.Substring(3, 2), 16); + int b = Convert.ToInt32(value.Substring(5, 2), 16); + return new Color(r / 255f, g / 255f, b / 255f, 1); + } + } + /// + /// The user’s display name. This tag may be empty if it is never set. + /// + public string DisplayName + { get + { + if (!MessageTags.TryGetValue("display-name", out string value)) + return ""; + return value ?? ""; + } + } + // public IEnumerable Emotes + // { get + // { + // var tag = TryGetTag("emotes"); + // foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries)) + // { + // var split = emote.Split(':', 2); + // Debug.Assert(split.Length == 2); + // var name = split[0]; + // foreach (var indeces in split[1].Split(',')) + // { + // var split2 = indeces.Split('-'); + // if (!int.TryParse(split2[0], out int start) || + // !int.TryParse(split2[1], out int end)) + // throw new InvalidDataException(); + // yield return new Emote(name, start, end - start + 1); + // } + // } + // } + // } + /// + /// An ID that uniquely identifies the message. + /// + public string Id => TryGetTag("id"); + /// + /// Whether the user is a moderator in this channel + /// + public bool Moderator + { get + { + if (!MessageTags.TryGetValue("mod", out string value)) + return false; + return value == "1"; + } + } + /// + /// An ID that identifies the chat room (channel). + /// + public string RoomId => TryGetTag("room-id"); + /// + /// Whether the user is subscribed to the channel + /// + public bool Subscriber + { get + { + if (!MessageTags.TryGetValue("subscriber", out string value)) + return false; + return value == "1"; + } + } + // public DateTime Timestamp + // { get + // { + // var s = TryGetTag("tmi-sent-ts"); + // if (!double.TryParse(s, out double result)) + // throw new InvalidDataException(); + // return DateTime.UnixEpoch.AddSeconds(result / 1000); + // } + // } + /// + /// A Boolean value that indicates whether the user has site-wide commercial + /// free mode enabled + /// + public bool Turbo + { get + { + if (!MessageTags.TryGetValue("turbo", out string value)) + return false; + return value == "1"; + } + } + /// + /// The user’s ID + /// + public string UserId + { get + { + if (!MessageTags.TryGetValue("user-id", out string value)) + return ""; + return value ?? ""; + } + } + // /// + // /// The type of the user. Assumes a normal user if this is not provided or is invalid. + // /// + // public UserType UserType + // { get + // { + // if (!MessageTags.TryGetValue("user-type", out string? value)) + // return UserType.Normal; + // switch (value) + // { + // case "admin": + // return UserType.Admin; + // case "global_mod": + // return UserType.GlobalMod; + // case "staff": + // return UserType.Staff; + // default: + // return UserType.Normal; + // } + // } + // } + /// + /// A Boolean value that determines whether the user that sent the chat is a VIP. + /// + public bool Vip => MessageTags.ContainsKey("vip"); + /// + /// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat. + /// + public int HypeChatLevel + { get + { + var value = TryGetTag("pinned-chat-paid-level"); + switch (value.ToUpper()) + { + case "ONE": + return 1; + case "TWO": + return 2; + case "THREE": + return 3; + case "FOUR": + return 4; + case "FIVE": + return 5; + case "SIX": + return 6; + case "SEVEN": + return 7; + case "EIGHT": + return 8; + case "NINE": + return 9; + case "TEN": + return 10; + default: + return 0; + } + } + } + /// + /// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in. + /// + public string HypeChatCurrency => TryGetTag("pinned-chat-paid-currency"); + public decimal? HypeChatValue + { get + { + var numeric = TryGetTag("pinned-chat-paid-amount"); + var exp = TryGetTag("pinned-chat-paid-exponent"); + if (int.TryParse(numeric, out int d_numeric) && int.TryParse(exp, out int d_exp)) + return d_numeric / ((decimal)Math.Pow(10, d_exp)); + return null; + } + } + public bool FirstMessage => TryGetTag("first-msg") == "1"; + public string ChatMessage => Parameters.Last(); + public Privmsg(TwitchChatMessage message) : base(message) + { + + } +} \ No newline at end of file diff --git a/Settings.cs b/Settings.cs new file mode 100644 index 0000000..24068cb --- /dev/null +++ b/Settings.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Godot; + +public partial class Settings : Node +{ + public bool AllowStreamer { get; set; } + public bool AllowModerators { get; set; } + public List UserWhitelist { get; } = new(); + public List UserBlacklist { get; } = new(); + public Vector2 CardSize { get; private set; } + + [Signal] + public delegate void ChangeCardSizeEventHandler(Vector2 size); + + public void SetCardSize(Vector2 size) + { + CardSize = size; + EmitSignal(SignalName.ChangeCardSize, size); + } + public bool IsUserAuthorized(string user, bool isStreamer = false, bool isModerator = false) + { + if (UserBlacklist.Contains(user)) + return false; + if (UserWhitelist.Contains(user)) + return true; + if (AllowStreamer && isStreamer) + return true; + if (AllowModerators && isModerator) + return true; + return false; + } + public void SetUserLists(IEnumerable white, IEnumerable black) + { + UserWhitelist.Clear(); + UserWhitelist.AddRange(white); + UserBlacklist.Clear(); + UserBlacklist.AddRange(black); + } +} \ No newline at end of file diff --git a/TwitchChatMessage.cs b/TwitchChatMessage.cs new file mode 100644 index 0000000..1704bc0 --- /dev/null +++ b/TwitchChatMessage.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Dynamic; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Text; +using System.Threading.Tasks; +public class TwitchChatMessage +{ + public static readonly string Delimter = "\r\n"; + public TwitchChatMessageType MessageType { get; protected set; } + public string Prefix { get; protected set; } + public string Source { get; protected set; } + public List Parameters { get; } = new(); + public string RawParameters { get; protected set; } + public string RawText { get; protected set; } + public TwitchMessageTags MessageTags { get; protected set; } = new(); + + protected TwitchChatMessage() + { + + } + protected TwitchChatMessage(TwitchChatMessage other) + { + MessageType = other.MessageType; + Prefix = other.Prefix; + Source = other.Source; + Parameters = new(other.Parameters); + RawParameters = other.RawParameters; + RawText = other.RawText; + MessageTags = new(other.MessageTags); + } + public static TwitchChatMessage Parse(string s) + { + TwitchChatMessage message = new(); + message.RawText = s; + //message has tags + if (s.StartsWith('@')) + { + s = s[1..]; + //first ' ' acts as the delimeter + var split = s.Split(' ', 2); + Debug.Assert(split.Length == 2, "no space found to end tag section"); + string tagString = split[0]; + s = split[1].TrimStart(' '); + message.MessageTags = TwitchMessageTags.Parse(tagString); + } + //message has source + if (s.StartsWith(':')) + { + s = s[1..]; + var split = s.Split(' ', 2); + Debug.Assert(split.Length == 2, "no space found to end prefix"); + message.Prefix = split[0]; + s = split[1].TrimStart(' '); + } + var spl_command = s.Split(' ', 2); + message.MessageType = TwitchChatMessageTypeHelper.Parse(spl_command[0]); + //message has parameters + if (spl_command.Length >= 2) + { + s = spl_command[1]; + message.RawParameters = s; + //message has single parameter marked as the final parameter + //this needs to be handled specially because the leading ' ' + //is stripped + if (s.StartsWith(':')) + { + message.Parameters.Add(s[1..]); + } + else + { + var spl_final = s.Split(" :", 2); + var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries); + message.Parameters.AddRange(spl_initial); + if (spl_final.Length >= 2) + message.Parameters.Add(spl_final[1]); + } + } + return message.MessageType switch + { + TwitchChatMessageType.PRIVMSG => new Privmsg(message), + _ => message, + }; + } + protected string TryGetTag(string s) + { + if (!MessageTags.TryGetValue(s, out string value)) + return ""; + return value ?? ""; + } + public static TwitchChatMessage MakePong(TwitchChatMessage ping) + { + var pong = new TwitchChatMessage(ping); + pong.MessageType = TwitchChatMessageType.PONG; + return pong; + } +} \ No newline at end of file diff --git a/TwitchChatMessageType.cs b/TwitchChatMessageType.cs new file mode 100644 index 0000000..5475da8 --- /dev/null +++ b/TwitchChatMessageType.cs @@ -0,0 +1,192 @@ +using System; + +public enum TwitchChatMessageType +{ + //twitch standard messages + JOIN = -1000, + NICK = -1001, + NOTICE = -1002, + PART = -1003, + PASS = -1004, + PING = -1005, + PONG = -1006, + PRIVMSG = -1007, + CLEARCHAT = -1008, + CLEARMSG = -1009, + GLOBALUSERSTATE = -1010, + HOSTTARGET = -1011, + RECONNECT = -1012, + ROOMSTATE = -1013, + USERNOTICE = -1014, + USERSTATE = -1015, + WHISPER = -1016, + + CAP = -2000, + + #region Numeric + RPL_WELCOME = 001, + RPL_YOURHOST = 002, + RPL_CREATED = 003, + RPL_MYINFO = 004, + RPL_ISUPPORT = 005, + RPL_BOUNCE = 010, + RPL_STATSCOMMANDS = 212, + RPL_ENDOFSTATS = 219, + RPL_UMODEIS = 221, + RPL_STATSUPTIME = 242, + RPL_LUSERCLIENT = 251, + RPL_LUSEROP = 252, + RPL_LUSERUNKNOWN = 253, + RPL_LUSERCHANNELS = 254, + RPL_LUSERME = 255, + RPL_ADMINME = 256, + RPL_ADMINLOC1 = 257, + RPL_ADMINLOC2 = 258, + RPL_ADMINEMAIL = 259, + RPL_TRYAGAIN = 263, + RPL_LOCALUSERS = 265, + RPL_GLOBALUSERS = 266, + RPL_WHOISCERTFP = 276, + RPL_NONE = 300, + RPL_AWAY = 301, + RPL_USERHOST = 302, + RPL_UNAWAY = 305, + RPL_NOWAWAY = 306, + RPL_WHOISREGNICK = 307, + RPL_WHOISUSER = 311, + RPL_WHOISSERVER = 312, + RPL_WHOISOPERATOR = 313, + RPL_WHOWASUSER = 314, + RPL_ENDOFWHO = 315, + RPL_WHOISIDLE = 317, + RPL_ENDOFWHOIS = 318, + RPL_WHOISCHANNELS = 319, + RPL_WHOISSPECIAL = 320, + RPL_LISTSTART = 321, + RPL_LIST = 322, + RPL_LISTEND = 323, + RPL_CHANNELMODEIS = 324, + RPL_CREATIONTIME = 329, + RPL_WHOISACCOUNT = 330, + RPL_NOTOPIC = 331, + RPL_TOPIC = 332, + RPL_TOPICWHOTIME = 333, + RPL_INVITELIST = 336, + RPL_ENDOFINVITELIST = 337, + RPL_WHOISACTUALLY = 338, + RPL_INVITING = 341, + RPL_INVEXLIST = 346, + RPL_ENDOFINVEXLIST = 347, + RPL_EXCEPTLIST = 348, + RPL_ENDOFEXCEPTLIST = 349, + RPL_VERSION = 351, + RPL_WHOREPLY = 352, + /// + /// Twitch seems to send this when you join a channel to list the users present + /// (even if documentation says it doesn't) + /// + RPL_NAMREPLY = 353, + RPL_LINKS = 364, + RPL_ENDOFLINKS = 365, + /// + /// Sent after a series of 353. + /// + RPL_ENDOFNAMES = 366, + RPL_BANLIST = 367, + RPL_ENDOFBANLIST = 368, + RPL_ENDOFWHOWAS = 369, + RPL_INFO = 371, + RPL_MOTD = 372, + RPL_ENDOFINFO = 374, + RPL_MOTDSTART = 375, + RPL_ENDOFMOTD = 376, + RPL_WHOISHOST = 378, + RPL_WHOISMODES = 379, + RPL_YOUREOPER = 381, + RPL_REHASHING = 382, + RPL_TIME = 391, + ERR_UNKNOWNERROR = 400, + ERR_NOSUCHNICK = 401, + ERR_NOSUCHSERVER = 402, + ERR_NOSUCHCHANNEL = 403, + ERR_CANNOTSENDTOCHANNEL = 404, + ERR_TOOMANYCHANNELS = 405, + ERR_WASNOSUCHNICK = 406, + ERR_NOORIGIN = 409, + ERR_NORECIPIENT = 411, + ERR_NOTEXTTOSEND = 412, + ERR_INPUTTOOLONG = 417, + /// + /// Twitch should send this if you try using an unsupported command + /// + ERR_UNKNOWNCOMMAND = 421, + ERR_NOMOTD = 422, + ERR_NONICKNAMEGIVEN = 431, + ERR_ERRONEUSNICKNAME = 432, + ERR_NICKNAMEINUSE = 433, + ERR_NICKCOLLISION = 436, + ERR_USERNOTINCHANNEL = 441, + ERR_NOTONCHANNEL = 442, + ERR_USERONCHANNEL = 443, + ERR_NOTREGISTERED = 451, + ERR_NEEDMOREPARAMS = 461, + ERR_ALREADYREGISTERED = 462, + ERR_PASSWDMISMATCH = 464, + ERR_YOUREBANNEDCREEP = 465, + ERR_CHANNELISFULL = 471, + ERR_UNKNOWNMODE = 472, + ERR_INVITEONLYCHAN = 473, + ERR_BANNEDFROMCHAN = 474, + ERR_BADCHANNELKEY = 475, + ERR_BADCHANMASK = 476, + ERR_NOPRIVILEGES = 481, + ERR_CHANOPRIVSNEEDED = 482, + ERR_CANTKILLSERVER = 483, + ERR_NOOPERHOST = 491, + ERR_UMODEUNKNOWNFLAG = 501, + ERR_USERSDONTMATCH = 502, + ERR_HELPNOTFOUND = 524, + ERR_INVALIDKEY = 525, + RPL_STARTTLS = 670, + RPL_WHOISSECURE = 671, + ERR_STARTTLS = 691, + ERR_INVALIDMODEPARAM = 696, + RPL_HELPSTART = 704, + RPL_HELPTXT = 705, + RPL_ENDOFHELP = 706, + RPL_NOPRIVS = 723, + RPL_LOGGEDIN = 900, + RPL_LOGGEDOUT = 901, + ERR_NICKLOCKED = 902, + RPL_SASLSUCCESS = 903, + ERR_SASLFAIL = 904, + ERR_SASLTOOLONG = 905, + ERR_SALSABORTED = 906, + ERR_SASLALREADY = 907, + ERR_SASLMECHS = 908, + #endregion //Numeric +} +public static class TwitchChatMessageTypeHelper +{ + /// + /// Parses a string that is either a numeric code or the command name. + /// + /// + /// + /// + /// The value range 000-999 is reserved for numeric commands, and will + /// be converted to a numeric string when forming a message. + /// + public static TwitchChatMessageType Parse(string s) + { + if (int.TryParse(s, out int result)) + return (TwitchChatMessageType)result; + return Enum.Parse(s); + } + public static string ToCommand(this TwitchChatMessageType type) + { + if ((int)type >= 0 && (int)type < 1000) + return $"{(int)type,3}"; + return type.ToString(); + } +} \ No newline at end of file diff --git a/TwitchChatWatcher.cs b/TwitchChatWatcher.cs new file mode 100644 index 0000000..4173b4d --- /dev/null +++ b/TwitchChatWatcher.cs @@ -0,0 +1,129 @@ +using Godot; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +public partial class TwitchChatWatcher : Node +{ + private readonly ClientWebSocket Socket = new(); + public readonly ConcurrentQueue Queue = new(); + private readonly CancellationTokenSource TokenSource = new(); + public CancellationToken Token => TokenSource.Token; + private CommandHandler CommandHandler { get; set; } + + [Signal] + public delegate void IncomingCommandEventHandler(Command command); + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + CommandHandler = GetNode("/root/CommandHandler"); + + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + } + public async Task ConnectAsync() + { + if (Socket.State == WebSocketState.Open) + return; + await Socket.ConnectAsync(new Uri("wss://irc-ws.chat.twitch.tv:443"), Token); + _ = Task.Run(GetPacketsTask, Token); + _ = Task.Run(HandleMessages, Token); + + } + public async Task Authenticate(string user = null, string pass = null) + { + user ??= $"justinfan{Random.Shared.NextInt64(10000):D4}"; + pass ??= "pass"; + await SendMessageAsync(TwitchChatMessageType.PASS, parameters: new string[] { pass }); + await SendMessageAsync(TwitchChatMessageType.NICK, parameters: new string[] { user }); + } + public async Task JoinChannel(string channel) + { + channel = channel.TrimStart('#'); + await SendMessageAsync(TwitchChatMessageType.JOIN, parameters: new string[] {"#" + channel}); + } + public async Task SendMessageAsync(string message) + { + await Socket.SendAsync(Encoding.UTF8.GetBytes(message), + WebSocketMessageType.Text, true, Token); + } + public async Task SendMessageAsync(TwitchChatMessageType command, IEnumerable parameters = null, + IDictionary tags = null, string prefix = null) + { + string EscapeTagValue(string s) + { + if (s is null) + return ""; + return string.Join("", s.Select(c => c switch + { + ';' => @"\:", + ' ' => @"\s", + '\\' => @"\\", + '\r' => @"\r", + '\n' => @"\n", + char ch => ch.ToString(), + })); + } + var message = ""; + if (tags is not null && tags.Count != 0) + { + message = "@" + string.Join(';', + tags.OrderBy(p => p.Key).Select(p => $"{p.Key}={EscapeTagValue(p.Value)}")) + + " "; + } + if (prefix is not null && !string.IsNullOrWhiteSpace(prefix)) + message += ":" + prefix + " "; + message += command.ToCommand() + " "; + if (parameters is not null && parameters.Any()) + { + message += string.Join(' ', parameters.SkipLast(1)); + message += " :" + parameters.Last(); + } + await SendMessageAsync(message); + } + private async Task GetPacketsTask() + { + var buffer = ArraySegment.Empty; + var stringData = ""; + while (!Token.IsCancellationRequested) + { + var res = await Socket.ReceiveAsync(buffer, Token); + if (Token.IsCancellationRequested) + return; + stringData += Encoding.UTF8.GetString(buffer); + var lines = stringData.Split("\r\n", StringSplitOptions.TrimEntries); + stringData = lines.Last(); + foreach (var line in lines.SkipLast(1)) + MessageStrings.Enqueue(line); + } + } + private readonly ConcurrentQueue MessageStrings = new(); + private void HandleMessages() + { + while (MessageStrings.TryDequeue(out string message)) + { + var tcm = TwitchChatMessage.Parse(message); + if (tcm.MessageType == TwitchChatMessageType.PING) + _ = SendPong(tcm); + else if (tcm is Privmsg p) + { + EmitSignal(SignalName.IncomingCommand, new Command(p.DisplayName, + false, p.Moderator, p.ChatMessage)); + } + } + } + private async Task SendPong(TwitchChatMessage ping) + { + var pong = TwitchChatMessage.MakePong(ping); + await SendMessageAsync(TwitchChatMessageType.PONG, ping.Parameters, + ping.MessageTags, ping.Prefix); + } +} diff --git a/TwitchMessageTags.cs b/TwitchMessageTags.cs new file mode 100644 index 0000000..2a445a8 --- /dev/null +++ b/TwitchMessageTags.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +public class TwitchMessageTags : IDictionary +{ + public Dictionary Tags = new(); + public TwitchMessageTags() + { + + } + public TwitchMessageTags(TwitchMessageTags other) + { + Tags = new(other); + } + private enum ParseState + { + FindingKey, + FindingValue, + ValueEscaped, + } + //TODO this should be unit tested + /// + /// + /// + /// + /// + public static TwitchMessageTags Parse(string s) + { + s = s.TrimStart('@'); + TwitchMessageTags tags = new(); + string key = ""; + string value = ""; + var state = ParseState.FindingKey; + foreach (char c in s) + { + switch (state) + { + case ParseState.FindingKey: + if (c == '=') + state = ParseState.FindingValue; + else if (c == ';') + { + state = ParseState.FindingKey; + tags.Add(key, ""); + key = ""; + } + else if (c == ' ') + { + tags.Add(key, ""); + goto EndParse; + } + else + key += c; + break; + case ParseState.FindingValue: + if (c == '\\') + { + state = ParseState.ValueEscaped; + } + else if (c == ';') + { + tags.Add(key, value); + key = value = ""; + state = ParseState.FindingKey; + } + else if (c == ' ') + { + tags.Add(key, value); + goto EndParse; + } + else if ("\r\n\0".Contains(c)) + throw new ArgumentException("Invalid character in tag string", nameof(s)); + else + { + value += c; + } + break; + case ParseState.ValueEscaped: + if (c == ':') + { + value += ';'; + state = ParseState.FindingValue; + } + else if (c == 's') + { + value += ' '; + state = ParseState.FindingValue; + } + else if (c == '\\') + { + value += '\\'; + state = ParseState.FindingValue; + } + else if (c == 'r') + { + value += '\r'; + state = ParseState.FindingValue; + } + else if (c == 'n') + { + value += '\n'; + state = ParseState.FindingValue; + } + else if (c == ';') + { + tags.Add(key, value); + key = value = ""; + state = ParseState.FindingKey; + } + //spaces should already be stripped, but handle this as end of tags just in case + else if (c == ' ') + { + tags.Add(key, value); + key = value = ""; + goto EndParse; + } + else if ("\r\n\0".Contains(c)) + throw new ArgumentException("Invalid character in tag string", nameof(s)); + else + { + value += c; + state = ParseState.FindingValue; + } + break; + default: + throw new InvalidEnumArgumentException("Invalid state enum"); + + } + } + //this is reached after processing the last character without hitting a space + tags.Add(key, value); + EndParse: + return tags; + } + #region IDictionary + public string this[string key] { get => ((IDictionary)Tags)[key]; set => ((IDictionary)Tags)[key] = value; } + + public ICollection Keys => ((IDictionary)Tags).Keys; + + public ICollection Values => ((IDictionary)Tags).Values; + + public int Count => ((ICollection>)Tags).Count; + + public bool IsReadOnly => ((ICollection>)Tags).IsReadOnly; + + public void Add(string key, string value) + { + ((IDictionary)Tags).Add(key, value); + } + + public void Add(KeyValuePair item) + { + ((ICollection>)Tags).Add(item); + } + + public void Clear() + { + ((ICollection>)Tags).Clear(); + } + + public bool Contains(KeyValuePair item) + { + return ((ICollection>)Tags).Contains(item); + } + + public bool ContainsKey(string key) + { + return ((IDictionary)Tags).ContainsKey(key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ((ICollection>)Tags).CopyTo(array, arrayIndex); + } + + public IEnumerator> GetEnumerator() + { + return ((IEnumerable>)Tags).GetEnumerator(); + } + + public bool Remove(string key) + { + return ((IDictionary)Tags).Remove(key); + } + + public bool Remove(KeyValuePair item) + { + return ((ICollection>)Tags).Remove(item); + } + + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) + { + return ((IDictionary)Tags).TryGetValue(key, out value); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)Tags).GetEnumerator(); + } + #endregion //IDictionary + +} \ No newline at end of file diff --git a/card.cs b/card.cs index cbc7441..414f55d 100644 --- a/card.cs +++ b/card.cs @@ -3,6 +3,7 @@ using System; public partial class card : Control { + private Settings Settings => GetNode("/root/Settings"); [Export] private string _CardName; public string CardName diff --git a/game.cs b/game.cs index 37decd5..fc81d87 100644 --- a/game.cs +++ b/game.cs @@ -18,6 +18,10 @@ public partial class game : Control PropogateCardSize(); } } + public void SetCardSize(Vector2 size) + { + CardSize = size; + } protected void PropogateCardSize() { foreach (var r in GetNode("%RowContainer").GetChildren().OfType()) @@ -36,7 +40,7 @@ public partial class game : Control { var c = card.MakeCard(GetTree()); GD.Print(c.CardId); - c.CardName = $"Card {i}"; + c.CardName = $"Card {c.CardId}"; if (GD.RandRange(0, 1) == 1) { //add to a row @@ -49,7 +53,7 @@ public partial class game : Control } } - + PropogateCardSize(); } // public override void _UnhandledInput(InputEvent @event) @@ -166,4 +170,95 @@ public partial class game : Control } return RemoveUnassignedCard(id); } + public card FindCard(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + foreach (var c in this.GetAllDescendents()) + if (c.CardId == id) + return c; + return null; + } + public row FindRow(string id) + { + foreach (var r in this.GetAllDescendents()) + if (r.RowId == id) + return r; + return null; + } + + #region Commands + public void MoveCard(string cardId, string targetRowId, int? toIndex = null) + { + var r = FindRow(targetRowId); + if (r is null) + throw new Exception($"row {r.RowId} not found"); + var c = ClaimCard(cardId); + if (c is null) + throw new Exception($"card {c.CardId} not found"); + r.AddCard(c, toIndex); + } + public void MoveRow(string rowId, int toIndex) + { + var r = FindRow(rowId); + if (r is null) + throw new Exception($"row {r.RowId} not found"); + //TODO what if out of range? + r.GetParent().MoveChild(r, toIndex); + } + public void DeleteCards(params string[] cardId) + { + //don't claim any cards unless all of them are found + if (!cardId.Select(FindCard).All(x => x is not null)) + throw new Exception("not all cards found"); + foreach (var c in cardId.Select(ClaimCard)) + { + c.QueueFree(); + } + } + public void DeleteRow(string rowId, bool deleteCards = false) + { + var r = FindRow(rowId); + if (r is null) + throw new Exception($"row {r.RowId} not found"); + } + public void CreateCard(string title = null, Image image = null) + { + var scn = GD.Load("res://card.tscn"); + var c = scn.Instantiate() as card; + if (!string.IsNullOrWhiteSpace(title)) + c.CardName = title; + if (image is not null) + c.SetTexture(ImageTexture.CreateFromImage(image)); + AddUnassignedCard(c); + } + public void CreateRow(Color? color = null, string title = null) + { + var scn = GD.Load("res://row.tscn"); + var r = scn.Instantiate() as row; + if (!string.IsNullOrWhiteSpace(title)) + r.RowText = title; + if (color is Color color1) + r.RowColor = color1; + } + public void RenameCard(string cardId, string newName) + { + + } + public void RenameRow(string rowId, string newTitle) + { + + } + public void RecolorRow(string rowId, Color color) + { + + } + public void ChangeCardImage(string cardId, Image image) + { + var c = FindCard(cardId); + if (c is null) + throw new Exception($"card {c.CardId} not found"); + c.SetTexture(ImageTexture.CreateFromImage(image)); + } + #endregion //Commands } diff --git a/game.tscn b/game.tscn index cf1da20..55ecdf3 100644 --- a/game.tscn +++ b/game.tscn @@ -1,12 +1,11 @@ -[gd_scene load_steps=6 format=3 uid="uid://ck0t4k3guvmfm"] +[gd_scene load_steps=7 format=3 uid="uid://ck0t4k3guvmfm"] [ext_resource type="PackedScene" uid="uid://b7pebyti48f7b" path="res://row.tscn" id="1_numg7"] [ext_resource type="Script" path="res://game.cs" id="1_vl33u"] -[ext_resource type="Script" path="res://UnassignedCardPanel.cs" id="3_dbs2t"] [ext_resource type="Script" path="res://PictureDropHandler.cs" id="3_owd27"] - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_yj5pd"] -bg_color = Color(0.788235, 0.788235, 0.788235, 1) +[ext_resource type="Script" path="res://TwitchChatWatcher.cs" id="5_qurdj"] +[ext_resource type="Script" path="res://CommandHandler.cs" id="5_yfhlo"] +[ext_resource type="PackedScene" uid="uid://jm7tss267q8y" path="res://settings_popup.tscn" id="6_e1cou"] [node name="Game" type="Control"] layout_mode = 3 @@ -62,16 +61,6 @@ _RowColor = Color(0.298039, 0.87451, 0.952941, 1) _RowText = "F" RowId = "5" -[node name="UnassignedCardPanel" type="PanelContainer" parent="GameContainer"] -layout_mode = 2 -size_flags_vertical = 3 -theme_override_styles/panel = SubResource("StyleBoxFlat_yj5pd") -script = ExtResource("3_dbs2t") - -[node name="UnassignedCardContainer" type="HFlowContainer" parent="GameContainer/UnassignedCardPanel"] -unique_name_in_owner = true -layout_mode = 2 - [node name="PictureDropHandler" type="Control" parent="."] unique_name_in_owner = true anchors_preset = 0 @@ -90,3 +79,13 @@ item_1/id = 1 item_count = 1 item_0/text = "" item_0/id = 0 + +[node name="TwitchChatWatcher" type="Node" parent="."] +script = ExtResource("5_qurdj") + +[node name="CommandHandler" type="Node" parent="."] +script = ExtResource("5_yfhlo") + +[node name="SettingsPopup" parent="." instance=ExtResource("6_e1cou")] + +[connection signal="IncomingCommand" from="TwitchChatWatcher" to="CommandHandler" method="IncomingCommand"] diff --git a/project.godot b/project.godot index 32a4c4a..570aa50 100644 --- a/project.godot +++ b/project.godot @@ -15,6 +15,20 @@ run/main_scene="res://game.tscn" config/features=PackedStringArray("4.2", "C#", "Forward Plus") config/icon="res://icon.svg" +[autoload] + +Settings="*res://Settings.cs" +CommandHandler="*res://CommandHandler.cs" +TwitchChatWatcher="*res://TwitchChatWatcher.cs" + [dotnet] project/assembly_name="TierMakerControl" + +[input] + +OpenMenu={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"echo":false,"script":null) +] +} diff --git a/row.cs b/row.cs index a6eb756..1932a10 100644 --- a/row.cs +++ b/row.cs @@ -6,6 +6,7 @@ using System.Linq; public partial class row : Control { + private Settings Settings => GetNode("/root/Settings"); [Export] private Color _RowColor; public Color RowColor @@ -77,22 +78,6 @@ public partial class row : Control // Called when the node enters the scene tree for the first time. public override void _Ready() { - if (false) - { - RowColor = new Color - { - R = 1, - G = 0, - B = 0, - A = 1, - }; - var scene = GD.Load("res://card.tscn"); - var card = scene.Instantiate(); - //GD.Print(card); - card.CardId = "new id"; - card.CardName = "new name"; - AddCard(card); - } //needs to wait until ready first PropogateColor(); PropogateCardSize(); @@ -135,14 +120,29 @@ public partial class row : Control throw new Exception($"Can't find card {c.CardId}"); } } - public void AddCard(card card) + public void AddCard(card card, int? toIndex = null) { - GetNode("%RowCardContainer").AddChild(card); + var n = GetNode("%RowCardContainer"); + n.AddChild(card); + if (toIndex is int i) + { + n.MoveChild(card, i); + } } public card GetCard(string id) { //inefficient to iterate through all children - return GetNode("%RowCardContainer").GetChildren().OfType().FirstOrDefault(c => c.CardId == id); + return GetNode("%RowCardContainer").GetChildren() + .OfType().FirstOrDefault(c => c.CardId == id); + } + public List ClaimAllCards() + { + var cs = this.GetAllDescendents().ToList(); + foreach (var c in cs) + { + RemoveChild(c); + } + return cs; } public card TryRemoveCard(string id) { diff --git a/row.tscn b/row.tscn index 66af743..f19ada8 100644 --- a/row.tscn +++ b/row.tscn @@ -19,10 +19,13 @@ outline_size = 2 outline_color = Color(0, 0, 0, 1) [node name="Row" type="MarginContainer" groups=["RowGroup"]] -anchors_preset = 10 -anchor_right = 1.0 +offset_right = 1152.0 grow_horizontal = 2 size_flags_vertical = 2 +theme_override_constants/margin_left = 0 +theme_override_constants/margin_top = 0 +theme_override_constants/margin_right = 0 +theme_override_constants/margin_bottom = 0 script = ExtResource("1_dodxa") [node name="RowGrid" type="PanelContainer" parent="."] diff --git a/settings_popup.cs b/settings_popup.cs new file mode 100644 index 0000000..778a208 --- /dev/null +++ b/settings_popup.cs @@ -0,0 +1,35 @@ +using Godot; +using System; +using System.Linq; +using System.Net.NetworkInformation; + +public partial class settings_popup : Popup +{ + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + } + public void _on_cancel_button_pressed() + { + Hide(); + } + public void _on_ok_button_pressed() + { + var settings = GetNode("/root/Settings"); + settings.AllowModerators = GetNode("%CheckBoxModerator").ButtonPressed; + settings.SetUserLists(GetNode("%WhiteListEdit").Text.Split('\n', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), + GetNode("%BlackListEdit").Text.Split('\n', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + var tcw = GetNode("/root/TwitchChatWatcher"); + tcw.ConnectAsync().RunSynchronously(); + tcw.Authenticate().RunSynchronously(); + tcw.JoinChannel(GetNode("%ChannelNameEdit").Text).RunSynchronously(); + Hide(); + } +} diff --git a/settings_popup.tscn b/settings_popup.tscn new file mode 100644 index 0000000..6571317 --- /dev/null +++ b/settings_popup.tscn @@ -0,0 +1,91 @@ +[gd_scene load_steps=2 format=3 uid="uid://jm7tss267q8y"] + +[ext_resource type="Script" path="res://settings_popup.cs" id="1_blkox"] + +[node name="SettingsPopup" type="Popup"] +title = "Settings" +size = Vector2i(720, 480) +visible = true +script = ExtResource("1_blkox") + +[node name="SettingsPopupContainer" type="TabContainer" parent="."] +custom_minimum_size = Vector2(720, 480) +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="ChatContainer" type="VBoxContainer" parent="SettingsPopupContainer"] +layout_mode = 2 + +[node name="ChannelNameContainer" type="HBoxContainer" parent="SettingsPopupContainer/ChatContainer"] +layout_mode = 2 + +[node name="ChannelNameLabel" type="Label" parent="SettingsPopupContainer/ChatContainer/ChannelNameContainer"] +layout_mode = 2 +text = "Channel Name" + +[node name="ChannelNameEdit" type="LineEdit" parent="SettingsPopupContainer/ChatContainer/ChannelNameContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(500, 0) +layout_mode = 2 + +[node name="CheckBoxModerator" type="CheckBox" parent="SettingsPopupContainer/ChatContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Allow moderators" + +[node name="UserListContainer" type="HBoxContainer" parent="SettingsPopupContainer/ChatContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="WhiteListContainer" type="VBoxContainer" parent="SettingsPopupContainer/ChatContainer/UserListContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="WhiteListLabel" type="Label" parent="SettingsPopupContainer/ChatContainer/UserListContainer/WhiteListContainer"] +layout_mode = 2 +text = "Whitelist" + +[node name="WhiteListEdit" type="TextEdit" parent="SettingsPopupContainer/ChatContainer/UserListContainer/WhiteListContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="BlackListContainer" type="VBoxContainer" parent="SettingsPopupContainer/ChatContainer/UserListContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="BlackListLabel" type="Label" parent="SettingsPopupContainer/ChatContainer/UserListContainer/BlackListContainer"] +layout_mode = 2 +text = "Blacklist +" + +[node name="BlackListEdit" type="TextEdit" parent="SettingsPopupContainer/ChatContainer/UserListContainer/BlackListContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="SettingsPopupContainer/ChatContainer"] +layout_mode = 2 +alignment = 2 + +[node name="CancelButton" type="Button" parent="SettingsPopupContainer/ChatContainer/HBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +size_flags_horizontal = 8 +text = "Cancel" + +[node name="OkButton" type="Button" parent="SettingsPopupContainer/ChatContainer/HBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +size_flags_horizontal = 8 +text = "OK" + +[connection signal="pressed" from="SettingsPopupContainer/ChatContainer/HBoxContainer/CancelButton" to="." method="_on_cancel_button_pressed"] +[connection signal="pressed" from="SettingsPopupContainer/ChatContainer/HBoxContainer/OkButton" to="." method="_on_ok_button_pressed"]