forgot to commit for a while

This commit is contained in:
2024-04-21 15:43:32 -05:00
parent 885bc5ad6e
commit 3ac7bf33c4
18 changed files with 1607 additions and 38 deletions

146
Command.cs Normal file
View File

@@ -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;
}
}
/// <summary>
/// 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 <see cref="Remaining"/>
/// after the previous arguments are popped.
/// </summary>
/// <remarks>
/// Use <see cref="DuplicateAtState"/> to safely handle subcommands.
/// </remarks>
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;
}
/// <summary>
/// Enumerates arguments from current state, without changing state
/// </summary>
/// <returns></returns>
public IEnumerable<string> Enumerate()
{
var a = DuplicateAtState();
while (true)
{
var s = a.Pop();
if (string.IsNullOrWhiteSpace(s))
break;
yield return s;
}
}
/// <summary>
/// Creates a new arguments object that returns to this state when reset.
/// Any arguments that have already been popped will not return.
/// </summary>
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;
}
}

202
CommandHandler.cs Normal file
View File

@@ -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<Task> 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<Settings>("/root/Settings");
Game = GetNode<game>("/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<Image> 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);
}
}

44
CommandParser.cs Normal file
View File

@@ -0,0 +1,44 @@
// using System.Collections.Generic;
// public static class CommandParser
// {
// public static List<string> SplitString(string s)
// {
// List<string> 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
// }
// }
// }
// }

13
GUI-only.build Normal file
View File

@@ -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"
}

256
PrivMsg.cs Normal file
View File

@@ -0,0 +1,256 @@
using Godot;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
public class Privmsg : TwitchChatMessage
{
/// <summary>
/// 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.
/// </summary>
public IEnumerable<string> BadgeInfo => TryGetTag("badge-info").Split(',');
/// <summary>
/// Contains the total number of months the user has subscribed, even if they aren't
/// subscribed currently.
/// </summary>
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;
}
}
// /// <summary>
// /// 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 fields
// /// value in the response. Then, match the version to the id field in the list of versions.
// /// </summary>
// public List<Badge> Badges
// { get
// {
// if (!MessageTags.TryGetValue("badges", out string? value))
// return [];
// if (value == null)
// return [];
// List<Badge> badges = [];
// foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
// {
// var spl = item.Split('/', 2);
// badges.Add(new Badge(spl[0], spl[1]));
// }
// return badges;
// }
// }
/// <summary>
/// The amount of bits cheered. Equals 0 if message did not contain a cheer.
/// </summary>
public int Bits
{ get
{
if (!MessageTags.TryGetValue("bits", out string value))
return 0;
if (!int.TryParse(value, out int bits))
return 0;
return bits;
}
}
/// <summary>
/// The color of the users name in the chat room. This is a hexadecimal
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
/// </summary>
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);
}
}
/// <summary>
/// The users display name. This tag may be empty if it is never set.
/// </summary>
public string DisplayName
{ get
{
if (!MessageTags.TryGetValue("display-name", out string value))
return "";
return value ?? "";
}
}
// public IEnumerable<Emote> 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);
// }
// }
// }
// }
/// <summary>
/// An ID that uniquely identifies the message.
/// </summary>
public string Id => TryGetTag("id");
/// <summary>
/// Whether the user is a moderator in this channel
/// </summary>
public bool Moderator
{ get
{
if (!MessageTags.TryGetValue("mod", out string value))
return false;
return value == "1";
}
}
/// <summary>
/// An ID that identifies the chat room (channel).
/// </summary>
public string RoomId => TryGetTag("room-id");
/// <summary>
/// Whether the user is subscribed to the channel
/// </summary>
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);
// }
// }
/// <summary>
/// A Boolean value that indicates whether the user has site-wide commercial
/// free mode enabled
/// </summary>
public bool Turbo
{ get
{
if (!MessageTags.TryGetValue("turbo", out string value))
return false;
return value == "1";
}
}
/// <summary>
/// The users ID
/// </summary>
public string UserId
{ get
{
if (!MessageTags.TryGetValue("user-id", out string value))
return "";
return value ?? "";
}
}
// /// <summary>
// /// The type of the user. Assumes a normal user if this is not provided or is invalid.
// /// </summary>
// 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;
// }
// }
// }
/// <summary>
/// A Boolean value that determines whether the user that sent the chat is a VIP.
/// </summary>
public bool Vip => MessageTags.ContainsKey("vip");
/// <summary>
/// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat.
/// </summary>
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;
}
}
}
/// <summary>
/// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.
/// </summary>
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)
{
}
}

39
Settings.cs Normal file
View File

@@ -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<string> UserWhitelist { get; } = new();
public List<string> 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<string> white, IEnumerable<string> black)
{
UserWhitelist.Clear();
UserWhitelist.AddRange(white);
UserBlacklist.Clear();
UserBlacklist.AddRange(black);
}
}

101
TwitchChatMessage.cs Normal file
View File

@@ -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<string> 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;
}
}

192
TwitchChatMessageType.cs Normal file
View File

@@ -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,
/// <summary>
/// Twitch seems to send this when you join a channel to list the users present
/// (even if documentation says it doesn't)
/// </summary>
RPL_NAMREPLY = 353,
RPL_LINKS = 364,
RPL_ENDOFLINKS = 365,
/// <summary>
/// Sent after a series of 353.
/// </summary>
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,
/// <summary>
/// Twitch should send this if you try using an unsupported command
/// </summary>
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
{
/// <summary>
/// Parses a string that is either a numeric code or the command name.
/// </summary>
/// <param name="s"></param>
/// <returns></returns>
/// <remarks>
/// The value range 000-999 is reserved for numeric commands, and will
/// be converted to a numeric string when forming a message.
/// </remarks>
public static TwitchChatMessageType Parse(string s)
{
if (int.TryParse(s, out int result))
return (TwitchChatMessageType)result;
return Enum.Parse<TwitchChatMessageType>(s);
}
public static string ToCommand(this TwitchChatMessageType type)
{
if ((int)type >= 0 && (int)type < 1000)
return $"{(int)type,3}";
return type.ToString();
}
}

129
TwitchChatWatcher.cs Normal file
View File

@@ -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<TwitchChatMessage> 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<CommandHandler>("/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<string> parameters = null,
IDictionary<string, string> 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<byte>.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<string> 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);
}
}

209
TwitchMessageTags.cs Normal file
View File

@@ -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<string, string>
{
public Dictionary<string, string> Tags = new();
public TwitchMessageTags()
{
}
public TwitchMessageTags(TwitchMessageTags other)
{
Tags = new(other);
}
private enum ParseState
{
FindingKey,
FindingValue,
ValueEscaped,
}
//TODO this should be unit tested
/// <summary>
///
/// </summary>
/// <param name="s"></param>
/// <returns></returns>
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<string, string?>
public string this[string key] { get => ((IDictionary<string, string>)Tags)[key]; set => ((IDictionary<string, string>)Tags)[key] = value; }
public ICollection<string> Keys => ((IDictionary<string, string>)Tags).Keys;
public ICollection<string> Values => ((IDictionary<string, string>)Tags).Values;
public int Count => ((ICollection<KeyValuePair<string, string>>)Tags).Count;
public bool IsReadOnly => ((ICollection<KeyValuePair<string, string>>)Tags).IsReadOnly;
public void Add(string key, string value)
{
((IDictionary<string, string>)Tags).Add(key, value);
}
public void Add(KeyValuePair<string, string> item)
{
((ICollection<KeyValuePair<string, string>>)Tags).Add(item);
}
public void Clear()
{
((ICollection<KeyValuePair<string, string>>)Tags).Clear();
}
public bool Contains(KeyValuePair<string, string> item)
{
return ((ICollection<KeyValuePair<string, string>>)Tags).Contains(item);
}
public bool ContainsKey(string key)
{
return ((IDictionary<string, string>)Tags).ContainsKey(key);
}
public void CopyTo(KeyValuePair<string, string>[] array, int arrayIndex)
{
((ICollection<KeyValuePair<string, string>>)Tags).CopyTo(array, arrayIndex);
}
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
{
return ((IEnumerable<KeyValuePair<string, string>>)Tags).GetEnumerator();
}
public bool Remove(string key)
{
return ((IDictionary<string, string>)Tags).Remove(key);
}
public bool Remove(KeyValuePair<string, string> item)
{
return ((ICollection<KeyValuePair<string, string>>)Tags).Remove(item);
}
public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value)
{
return ((IDictionary<string, string>)Tags).TryGetValue(key, out value);
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)Tags).GetEnumerator();
}
#endregion //IDictionary<string, string?>
}

View File

@@ -3,6 +3,7 @@ using System;
public partial class card : Control
{
private Settings Settings => GetNode<Settings>("/root/Settings");
[Export]
private string _CardName;
public string CardName

99
game.cs
View File

@@ -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<row>())
@@ -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<card>())
if (c.CardId == id)
return c;
return null;
}
public row FindRow(string id)
{
foreach (var r in this.GetAllDescendents<row>())
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<PackedScene>("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<PackedScene>("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
}

View File

@@ -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"]

View File

@@ -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)
]
}

38
row.cs
View File

@@ -6,6 +6,7 @@ using System.Linq;
public partial class row : Control
{
private Settings Settings => GetNode<Settings>("/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<PackedScene>("res://card.tscn");
var card = scene.Instantiate<card>();
//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<card>().FirstOrDefault(c => c.CardId == id);
return GetNode("%RowCardContainer").GetChildren()
.OfType<card>().FirstOrDefault(c => c.CardId == id);
}
public List<card> ClaimAllCards()
{
var cs = this.GetAllDescendents<card>().ToList();
foreach (var c in cs)
{
RemoveChild(c);
}
return cs;
}
public card TryRemoveCard(string id)
{

View File

@@ -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="."]

35
settings_popup.cs Normal file
View File

@@ -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<Settings>("/root/Settings");
settings.AllowModerators = GetNode<CheckBox>("%CheckBoxModerator").ButtonPressed;
settings.SetUserLists(GetNode<TextEdit>("%WhiteListEdit").Text.Split('\n',
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
GetNode<TextEdit>("%BlackListEdit").Text.Split('\n',
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
var tcw = GetNode<TwitchChatWatcher>("/root/TwitchChatWatcher");
tcw.ConnectAsync().RunSynchronously();
tcw.Authenticate().RunSynchronously();
tcw.JoinChannel(GetNode<LineEdit>("%ChannelNameEdit").Text).RunSynchronously();
Hide();
}
}

91
settings_popup.tscn Normal file
View File

@@ -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"]