diff --git a/TwitchIrcClient/IRC/Messages/ClearChat.cs b/TwitchIrcClient/IRC/Messages/ClearChat.cs index 6e9ef91..1aa22b0 100644 --- a/TwitchIrcClient/IRC/Messages/ClearChat.cs +++ b/TwitchIrcClient/IRC/Messages/ClearChat.cs @@ -30,13 +30,13 @@ namespace TwitchIrcClient.IRC.Messages /// The ID of the user that was banned or put in a timeout. /// public string TargetUserId => TryGetTag("target-user-id"); - public DateTime? TmiSentTime + public DateTime? Timestamp { get { string s = TryGetTag("tmi-sent-ts"); if (!double.TryParse(s, out double d)) return null; - return DateTime.UnixEpoch.AddSeconds(d); + return DateTime.UnixEpoch.AddMilliseconds(d); } } /// @@ -52,12 +52,12 @@ namespace TwitchIrcClient.IRC.Messages /// /// The name of the channel that either was cleared or banned the user /// - public string Channel => Parameters.First(); + public string Channel => Parameters.First().TrimStart('#'); /// /// The username of the banned user, or "" if message is a /// channel clear. /// - public string User => Parameters.ElementAtOrDefault(2) ?? ""; + public string User => Parameters.ElementAtOrDefault(1) ?? ""; public ClearChat(ReceivedMessage message) : base(message) { Debug.Assert(MessageType == IrcMessageType.CLEARCHAT, diff --git a/TwitchIrcClient/IRC/Messages/ClearMsg.cs b/TwitchIrcClient/IRC/Messages/ClearMsg.cs index 517c7c8..71a1124 100644 --- a/TwitchIrcClient/IRC/Messages/ClearMsg.cs +++ b/TwitchIrcClient/IRC/Messages/ClearMsg.cs @@ -28,7 +28,7 @@ namespace TwitchIrcClient.IRC.Messages /// /// /// - public DateTime? TmiSentTime + public DateTime? Timestamp { get { string s = TryGetTag("tmi-sent-ts"); @@ -37,6 +37,8 @@ namespace TwitchIrcClient.IRC.Messages return DateTime.UnixEpoch.AddSeconds(d / 1000); } } + public string Channel => Parameters.FirstOrDefault("").TrimStart('#'); + public string Message => Parameters.LastOrDefault(""); public ClearMsg(ReceivedMessage message) : base(message) { Debug.Assert(MessageType == IrcMessageType.CLEARMSG, diff --git a/TwitchIrcClient/IRC/Messages/Emote.cs b/TwitchIrcClient/IRC/Messages/Emote.cs index f4aeae8..2441c3c 100644 --- a/TwitchIrcClient/IRC/Messages/Emote.cs +++ b/TwitchIrcClient/IRC/Messages/Emote.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; namespace TwitchIrcClient.IRC.Messages { - public record struct Emote(string Name, int Length) + public record struct Emote(string Name, int Position, int Length) { } diff --git a/TwitchIrcClient/IRC/Messages/GlobalUserState.cs b/TwitchIrcClient/IRC/Messages/GlobalUserState.cs new file mode 100644 index 0000000..3cd3e0f --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/GlobalUserState.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace TwitchIrcClient.IRC.Messages +{ + public class GlobalUserState : ReceivedMessage + { + /// + /// Contains metadata related to the chat badges in the badges tag. + /// Currently, this tag contains metadata only for subscriber badges, + /// to indicate the number of months the user has been a subscriber. + /// + public IEnumerable BadgeInfo => TryGetTag("badge-info").Split(','); + /// + /// 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 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 System.Drawing.Color.FromArgb(r, g, b); + } + } + /// + /// 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 ?? ""; + } + } + /// + /// A comma-delimited list of IDs that identify the emote sets that the user has + /// access to. Is always set to at least zero (0). To access the emotes in the set, + /// use the Get Emote Sets API. + /// + /// + public IEnumerable EmoteSets + { get + { + var value = TryGetTag("emote-sets"); + foreach (var s in value.Split(',')) + { + if (int.TryParse(s, out int num)) + yield return num; + else + throw new InvalidDataException(); + } + } + } + /// + /// 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"; + } + } + public string UserId => TryGetTag("user-id"); + /// + /// 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; + } + } + } + public GlobalUserState(ReceivedMessage other) : base(other) + { + Debug.Assert(MessageType == IrcMessageType.GLOBALUSERSTATE, + $"{nameof(GlobalUserState)} must have type {IrcMessageType.GLOBALUSERSTATE}" + + $" but has {MessageType}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/HostTarget.cs b/TwitchIrcClient/IRC/Messages/HostTarget.cs new file mode 100644 index 0000000..ab1be1b --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/HostTarget.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchIrcClient.IRC.Messages +{ + public class HostTarget : ReceivedMessage + { + /// + /// The channel that’s hosting the viewers. + /// + public string HostingChannel => Parameters.FirstOrDefault("").TrimStart('#'); + public string ChannelBeingHosted => + Parameters.Last().Split(' ').First().TrimStart('-'); + /// + /// true if the channel is now hosting another channel, false if it stopped hosting + /// + public bool NowHosting => !Parameters.Last().StartsWith('-'); + public int NumberOfViewers + { get + { + var s = Parameters.LastOrDefault(""); + var s2 = s.Split(' ', StringSplitOptions.TrimEntries).LastOrDefault(""); + if (int.TryParse(s2, out int value)) + return value; + return 0; + } + } + + + public HostTarget(ReceivedMessage other) : base(other) + { + Debug.Assert(MessageType == IrcMessageType.HOSTTARGET, + $"{nameof(HostTarget)} must have type {IrcMessageType.HOSTTARGET}" + + $" but has {MessageType}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/Notice.cs b/TwitchIrcClient/IRC/Messages/Notice.cs index cbb7c10..5fed21a 100644 --- a/TwitchIrcClient/IRC/Messages/Notice.cs +++ b/TwitchIrcClient/IRC/Messages/Notice.cs @@ -160,166 +160,4 @@ namespace TwitchIrcClient.IRC.Messages usage_me, usage_mod, } - //public enum NoticeId - //{ - // AlreadyBanned, - // AlreadyEmoteOnlyOff, - // AlreadyEmoteOnlyOn, - // AlreadyFollowersOff, - // AlreadyFollowersOn, - // AlreadyR9KOff, - // AlreadyR9KOn, - // AlreadySlowOff, - // AlreadySlowOn, - // AlreadySubsOff, - // AlreadySubsOn, - // AutohostReceive, - // BadBanAdmin, - // BadBanAnon, - // BadBanBroadcaster, - // BadBanMod, - // BadBanSelf, - // BadBanStaff, - // BadCommercialError, - // BadDeleteMessageBroadcaster, - // BadDeleteMessageMod, - // BadHostError, - // BadHostHosting, - // BadHostRateExceeded, - // BadHostRejected, - // BadHostSelf, - // BadModBanned, - // BadModMod, - // BadSlowDuration, - // BadTimeoutAdmin, - // BadTimeoutAnon, - // BadTimeoutBroadcaster, - // BadTimeoutDuration, - // BadTimeoutMod, - // BadTimeoutSelf, - // BadTimeoutStaff, - // BadUnbanNoBan, - // BadUnhostError, - // BadUnmodMod, - // BadVipGranteeBanned, - // BadVipGranteeAlreadyVip, - // BadVipMaxVipsReached, - // BadVipAchievementIncomplete, - // BadUnvipGranteeNotVip, - // BanSuccess, - // CmdsAvailable, - // ColorChanged, - // CommercialSuccess, - // DeleteMessageSuccess, - // DeleteStaffMessageSuccess, - // EmoteOnlyOff, - // EmoteOnlyOn, - // FollowersOff, - // FollowersOn, - // FollowersOnZero, - // HostOff, - // HostOn, - // HostReceive, - // HostReceiveNoCount, - // HostTargetWentOffline, - // HostsRemaining, - // InvalidUser, - // ModSuccess, - // MsgBanned, - // MsgBadCharacters, - // MsgChannelBlocked, - // MsgChannelSuspended, - // MsgDuplicate, - // MsgEmoteonly, - // MsgFollowersonly, - // MsgFollowersonlyFollowed, - // MsgFollowersonlyZero, - // MsgR9K, - // MsgRatelimit, - // MsgRejected, - // MsgRejectedMandatory, - // MsgRequiresVerifiedPhoneNumber, - // MsgSlowmode, - // MsgSubsonly, - // MsgSuspended, - // MsgTimedout, - // MsgVerifiedEmail, - // NoHelp, - // NoMods, - // NoVips, - // NotHosting, - // NoPermission, - // R9KOff, - // R9KOn, - // RaidErrorAlreadyRaiding, - // RaidErrorForbidden, - // RaidErrorSelf, - // RaidErrorTooManyViewers, - // RaidErrorUnexpected, - // RaidNoticeMature, - // RaidNoticeRestrictedChat, - // RoomMods, - // SlowOff, - // SlowOn, - // SubsOff, - // SubsOn, - // TimeoutNoTimeout, - // TimeoutSuccess, - // TosBan, - // TurboOnlyColor, - // UnavailableCommand, - // UnbanSuccess, - // UnmodSuccess, - // UnraidErrorNoActiveRaid, - // UnraidErrorUnexpected, - // UnraidSuccess, - // UnrecognizedCmd, - // UntimeoutBanned, - // UntimeoutSuccess, - // UnvipSuccess, - // UsageBan, - // UsageClear, - // UsageColor, - // UsageCommercial, - // UsageDisconnect, - // UsageDelete, - // UsageEmoteOnlyOff, - // UsageEmoteOnlyOn, - // UsageFollowersOff, - // UsageFollowersOn, - // UsageHelp, - // UsageHost, - // UsageMarker, - // UsageMe, - // UsageMod, - // UsageMods, - // UsageR9KOff, - // UsageR9KOn, - // UsageRaid, - // UsageSlowOff, - // UsageSlowOn, - // UsageSubsOff, - // UsageSubsOn, - // UsageTimeout, - // UsageUnban, - // UsageUnhost, - // UsageUnmod, - // UsageUnraid, - // UsageUntimeout, - // UsageUnvip, - // UsageUser, - // UsageVip, - // UsageVips, - // UsageWhisper, - // VipSuccess, - // VipsSuccess, - // WhisperBanned, - // WhisperBannedRecipient, - // WhisperInvalidLogin, - // WhisperInvalidSelf, - // WhisperLimitPerMin, - // WhisperLimitPerSec, - // WhisperRestricted, - // WhisperRestrictedRecipient, - //} } diff --git a/TwitchIrcClient/IRC/Messages/Privmsg.cs b/TwitchIrcClient/IRC/Messages/Privmsg.cs index f8fefdf..5fcf219 100644 --- a/TwitchIrcClient/IRC/Messages/Privmsg.cs +++ b/TwitchIrcClient/IRC/Messages/Privmsg.cs @@ -18,8 +18,8 @@ namespace TwitchIrcClient.IRC.Messages /// 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. + /// 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 @@ -79,6 +79,26 @@ namespace TwitchIrcClient.IRC.Messages 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. /// @@ -166,6 +186,54 @@ namespace TwitchIrcClient.IRC.Messages /// 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(ReceivedMessage message) : base(message) diff --git a/TwitchIrcClient/IRC/Messages/ReceivedMessage.cs b/TwitchIrcClient/IRC/Messages/ReceivedMessage.cs index 153d780..958ff35 100644 --- a/TwitchIrcClient/IRC/Messages/ReceivedMessage.cs +++ b/TwitchIrcClient/IRC/Messages/ReceivedMessage.cs @@ -94,27 +94,23 @@ namespace TwitchIrcClient.IRC.Messages message.Parameters.Add(spl_final[1]); } } - switch (message.MessageType) + return message.MessageType switch { - case IrcMessageType.PRIVMSG: - return new Privmsg(message); - case IrcMessageType.CLEARCHAT: - return new ClearChat(message); - case IrcMessageType.CLEARMSG: - return new ClearMsg(message); - case IrcMessageType.NOTICE: - return new Notice(message); - case IrcMessageType.JOIN: - return new Join(message); - case IrcMessageType.PART: - return new Part(message); - case IrcMessageType.RPL_NAMREPLY: - return new NamReply(message); - case IrcMessageType.ROOMSTATE: - return new Roomstate(message); - default: - return message; - } + IrcMessageType.CLEARCHAT => new ClearChat(message), + IrcMessageType.CLEARMSG => new ClearMsg(message), + IrcMessageType.JOIN => new Join(message), + IrcMessageType.GLOBALUSERSTATE => new GlobalUserState(message), + IrcMessageType.HOSTTARGET => new HostTarget(message), + IrcMessageType.NOTICE => new Notice(message), + IrcMessageType.PART => new Part(message), + IrcMessageType.PRIVMSG => new Privmsg(message), + IrcMessageType.ROOMSTATE => new Roomstate(message), + IrcMessageType.RPL_NAMREPLY => new NamReply(message), + IrcMessageType.USERNOTICE => new UserNotice(message), + IrcMessageType.USERSTATE => new UserState(message), + IrcMessageType.WHISPER => new Whisper(message), + _ => message, + }; } /// /// Tries to get the value of the tag. diff --git a/TwitchIrcClient/IRC/Messages/UserNotice.cs b/TwitchIrcClient/IRC/Messages/UserNotice.cs index 112cf81..e3a16b0 100644 --- a/TwitchIrcClient/IRC/Messages/UserNotice.cs +++ b/TwitchIrcClient/IRC/Messages/UserNotice.cs @@ -1,12 +1,19 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Data; using System.Diagnostics; using System.Drawing; +using System.IO; using System.Linq; using System.Net.WebSockets; +using System.Numerics; using System.Text; +using System.Threading.Channels; using System.Threading.Tasks; +using System.Xml.Linq; +using static System.Net.Mime.MediaTypeNames; +using static System.Runtime.InteropServices.JavaScript.JSType; namespace TwitchIrcClient.IRC.Messages { @@ -16,7 +23,7 @@ namespace TwitchIrcClient.IRC.Messages /// 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 + /// 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 @@ -52,6 +59,8 @@ namespace TwitchIrcClient.IRC.Messages return System.Drawing.Color.FromArgb(r, g, b); } } + public string Channel => Parameters.FirstOrDefault("").TrimStart('#'); + public string Message => Parameters.ElementAtOrDefault(1) ?? ""; /// /// The user’s display name. This tag may be empty if it is never set. /// @@ -63,6 +72,13 @@ namespace TwitchIrcClient.IRC.Messages return value ?? ""; } } + public IEnumerable Emotes + { get + { + throw new NotImplementedException(); + + } + } /// /// An ID that uniquely identifies the message. /// @@ -70,6 +86,10 @@ namespace TwitchIrcClient.IRC.Messages public UserNoticeType? UserNoticeType => Enum.TryParse(TryGetTag("msg-id"), out UserNoticeType type) ? type : null; /// + /// + /// + public string Login => TryGetTag("login"); + /// /// Whether the user is a moderator in this channel /// public bool Moderator @@ -118,24 +138,166 @@ namespace TwitchIrcClient.IRC.Messages return value ?? ""; } } + /// + /// + /// + public string SystemMessage => TryGetTag("system-msg"); + /// + /// When the Twitch IRC server received the message + /// + public DateTime Timestamp + { get + { + if (double.TryParse(TryGetTag("tmi-sent-ts"), out double value)) + return DateTime.UnixEpoch.AddMilliseconds(value); + throw new InvalidDataException(); + } + } + /// + /// Included only with notices. + /// The display name of the broadcaster raiding this channel. + /// + public string RaidingChannelDisplayName => TryGetTag("msg-param-displayName"); + /// + /// Included only with notices. + /// The login name of the broadcaster raiding this channel. + /// + public string RaidingChannelLogin => TryGetTag("msg-param-login"); + /// + /// Included only with and notices. + /// The subscriptions promo, if any, that is ongoing (for example, Subtember 2018). + /// + public string SubscriptionPromoName => TryGetTag("msg-param-promo-name"); + /// + /// Included only with and + /// notices. + /// The number of gifts the gifter has given during the promo indicated by . + /// + public int SubscriptionPromoCount => int.TryParse(TryGetTag("msg-param-promo-gift-total"), + out int value) ? value : 0; + /// + /// Included only with notices. + /// The display name of the subscription gift recipient. + /// + public string RecipientDisplayName => TryGetTag("msg-param-recipient-display-name"); + /// + /// Included only with notices. + /// The user ID of the subscription gift recipient. + /// + public string RecipientId => TryGetTag("msg-param-recipient-id"); + /// + /// Included only with notices. + /// The user name of the subscription gift recipient. + /// + public string RecipientUsername => TryGetTag("msg-param-recipient-user-name"); + /// + /// Only Included in , , + /// and . + /// Either "msg-param-cumulative-months" or "msg-param-months" depending + /// on the notice type. + /// + public int TotalMonths + { get + { + var s1 = TryGetTag("msg-param-cumulative-months"); + var s2 = TryGetTag("msg-param-months"); + if (int.TryParse(s1, out int value1)) + return value1; + if (int.TryParse(s2, out int value2)) + return value2; + return 0; + } + } + /// + /// Included only with and notices. + /// A Boolean value that indicates whether the user wants their streaks shared. + /// Is "false" for other message types. + /// + public bool ShouldShareStreak => TryGetTag("msg-param-should-share-streak") + == "1" ? true : false; + /// + /// Included only with and notices. + /// The number of consecutive months the user has subscribed. + /// This is zero(0) if is 0. + /// + public int StreakMonths => int.TryParse(TryGetTag("msg-param-streak-months"), + out int value) ? value : 0; + /// + /// Included only with , + /// and notices. + /// + public SubType SubPlan + { get + { + switch (TryGetTag("msg-param-sub-plan").ToUpper()) + { + case "PRIME": + return SubType.Prime; + case "1000": + return SubType.T1; + case "2000": + return SubType.T2; + case "3000": + return SubType.T3; + default: + return SubType.None; + } + } + } + /// + /// Included only with , , + /// and notices. + /// The display name of the subscription plan. This may be a default name or one created + /// by the channel owner. + /// + public string SubPlanName => TryGetTag("msg-param-sub-plan-name"); + /// + /// Included only with notices. + /// The number of viewers raiding this channel from the broadcaster’s channel. + /// + public int ViewerCount => int.TryParse(TryGetTag("msg-param-viewerCount"), + out int value) ? value : 0; + /// + /// The type of user sending the whisper message. + /// public UserType UserType { get { if (!MessageTags.TryGetValue("user-type", out string? value)) - return UserType.Normal; - switch (value) + return UserType.None; + switch (value.ToUpper()) { - case "admin": + case "ADMIN": return UserType.Admin; - case "global_mod": + case "GLOBAL_MOD": return UserType.GlobalMod; - case "staff": + case "STAFF": return UserType.Staff; - default: + case "": return UserType.Normal; + default: + throw new InvalidDataException(); } } } + /// + /// Included only with notices. + /// The name of the ritual being celebrated. + /// + public RitualType RitualType => Enum.TryParse(TryGetTag("msg-param-ritual-name"), + out RitualType rt) ? rt : RitualType.None; + //TODO possibly deprecate and add an int version in the future if all tiers are numeric + /// + /// Included only with notices. + /// The tier of the Bits badge the user just earned. For example, 100, 1000, or 10000. + /// + public string Threshold => TryGetTag("msg-param-threshold"); + /// + /// Included only with notices. + /// The number of months gifted as part of a single, multi-month gift. + /// + public int GiftMonths => int.TryParse(TryGetTag("msg-param-gift-months"), + out int value) ? value : 0; public UserNotice(ReceivedMessage message) : base(message) { Debug.Assert(MessageType == IrcMessageType.USERNOTICE, @@ -145,16 +307,31 @@ namespace TwitchIrcClient.IRC.Messages } public enum UserNoticeType { - sub, - resub, - subgift, - submysterygift, - giftpaidupgrade, - rewardgift, - anongiftpaidupgrade, - raid, - unraid, - ritual, - bitsbadgetier, + sub = 0, + resub = 1, + subgift = 2, + submysterygift = 3, + giftpaidupgrade = 4, + rewardgift = 5, + anongiftpaidupgrade = 6, + raid = 7, + unraid = 8, + ritual = 9, + bitsbadgetier = 10, + } + public enum RitualType + { + new_chatter = 0, + + None = int.MinValue, + } + public enum SubType + { + Prime = 0, + T1 = 1, + T2 = 2, + T3 = 3, + + None = int.MinValue, } } diff --git a/TwitchIrcClient/IRC/Messages/UserState.cs b/TwitchIrcClient/IRC/Messages/UserState.cs new file mode 100644 index 0000000..1e7665b --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/UserState.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchIrcClient.IRC.Messages +{ + public class UserState : ReceivedMessage + { + public string Channel => Parameters.FirstOrDefault("").TrimStart('#'); + /// + /// 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 System.Drawing.Color.FromArgb(r, g, b); + } + } + /// + /// The user’s display name, escaped as described in the IRCv3 spec. + /// + public string DisplayName => TryGetTag("display-name"); + /// + /// A comma-delimited list of IDs that identify the emote sets that the user has + /// access to. Is always set to at least zero (0). To access the emotes in the set, + /// use the Get Emote Sets API. + /// + /// + public IEnumerable EmoteSets + { get + { + var value = TryGetTag("emote-sets"); + foreach (var s in value.Split(',')) + { + if (int.TryParse(s, out int num)) + yield return num; + else + throw new InvalidDataException(); + } + } + } + /// + /// If a privmsg was sent, an ID that uniquely identifies the message. + /// + public string Id => TryGetTag("id"); + /// + /// A Boolean value that determines whether the user is a moderator. + /// + public bool Moderator + { get + { + if (!MessageTags.TryGetValue("mod", out string? value)) + return false; + return value == "1"; + } + } + /// + /// Whether the user is subscribed to the channel + /// + public bool Subscriber + { get + { + if (!MessageTags.TryGetValue("subscriber", out string? value)) + return false; + return value == "1"; + } + } + /// + /// 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"; + } + } + 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; + } + } + } + + public UserState(ReceivedMessage other) : base(other) + { + Debug.Assert(MessageType == IrcMessageType.USERSTATE, + $"{nameof(UserState)} must have type {IrcMessageType.USERSTATE}" + + $" but has {MessageType}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/Whisper.cs b/TwitchIrcClient/IRC/Messages/Whisper.cs new file mode 100644 index 0000000..5efabc5 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/Whisper.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchIrcClient.IRC.Messages +{ + public class Whisper : ReceivedMessage + { + + /// + /// 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 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 System.Drawing.Color.FromArgb(r, g, b); + } + } + /// + /// 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 whisper message. + /// + public string MessageId => TryGetTag("message-id"); + /// + /// An ID that uniquely identifies the whisper thread. + /// The ID is in the form, _. + /// + public string ThreadId => TryGetTag("thread-id"); + /// + /// 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 ID of the user sending the whisper message. + /// + public string UserId => TryGetTag("user-id"); + public string Message => Parameters.LastOrDefault(""); + /// + /// 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; + } + } + } + public Whisper(ReceivedMessage other) : base(other) + { + Debug.Assert(MessageType == IrcMessageType.WHISPER, + $"{nameof(Whisper)} must have type {IrcMessageType.WHISPER}" + + $" but has {MessageType}"); + } + } +} diff --git a/TwitchIrcClientTests/ParserTest.cs b/TwitchIrcClientTests/ParserTest.cs index e81cbe3..e672b58 100644 --- a/TwitchIrcClientTests/ParserTest.cs +++ b/TwitchIrcClientTests/ParserTest.cs @@ -2,6 +2,7 @@ using System.Drawing; using System; using TwitchIrcClient.IRC; using TwitchIrcClient.IRC.Messages; +using System.Diagnostics; namespace TwitchIrcClientTests { @@ -9,24 +10,9 @@ namespace TwitchIrcClientTests public class ParserTest { [TestMethod] - public void TestSimpleMessages() + public void TestRoomstate() { var ROOMSTATE = "@emote-only=0;followers-only=-1;r9k=0;room-id=321654987;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #channelname"; - var NAMREPLY = ":justinfan7550.tmi.twitch.tv 353 justinfan7550 = #channelname :user1 user2 user3 user4 user5"; - var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname"; - var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname"; - var PRIVMSG = "@badge-info=subscriber/1;badges=subscriber/0;client-nonce=202e32113a3768963eded865e051fc5b;color=#AAAAFF;" + - "display-name=ChattingUser;emotes=;first-msg=0;flags=;id=24fe75a1-06a5-4078-a31f-cf615107b2a2;mod=0;returning-chatter=0;" + - "room-id=321654987;subscriber=1;tmi-sent-ts=1710920497332;turbo=0;user-id=01234567;user-type= " + - ":chattinguser!chattinguser@chattinguser.tmi.twitch.tv PRIVMSG #channelname :This is a test chat message"; - var CHEER = "@badge-info=subscriber/9;badges=subscriber/9,twitch-recap-2023/1;bits=100;color=#FF0000;display-name=CheeringUser;" + - //I haven't fixed this emote tag after rewriting the message - "emotes=emotesv2_44a39d65e08f43adac871a80e9b96d85:17-24;first-msg=1;flags=;id=5eab1319-5d46-4c55-be29-33c2f834e42e;mod=0;" + - "returning-chatter=0;room-id=321654987;subscriber=0;tmi-sent-ts=1710920826069;turbo=1;user-id=012345678;user-type=;vip " + - ":cheeringuser!cheeringuser@cheeringuser.tmi.twitch.tv PRIVMSG #channelname :This includes a cheer Cheer100"; - //var CLEARMSG = ""; - //var CLEARROOM = ""; - var _roomstate = ReceivedMessage.Parse(ROOMSTATE); Assert.AreEqual(IrcMessageType.ROOMSTATE, _roomstate.MessageType); if (_roomstate is Roomstate roomstate) @@ -55,7 +41,11 @@ namespace TwitchIrcClientTests { Assert.Fail(); } - + } + [TestMethod] + public void TestNamreply() + { + var NAMREPLY = ":justinfan7550.tmi.twitch.tv 353 justinfan7550 = #channelname :user1 user2 user3 user4 user5"; var _namReply = ReceivedMessage.Parse(NAMREPLY); Assert.AreEqual(IrcMessageType.RPL_NAMREPLY, _namReply.MessageType); if (_namReply is NamReply namReply) @@ -68,7 +58,11 @@ namespace TwitchIrcClientTests { Assert.Fail(); } - + } + [TestMethod] + public void TestJoin() + { + var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname"; var _join = ReceivedMessage.Parse(JOIN); Assert.AreEqual(IrcMessageType.JOIN, _join.MessageType); if (_join is Join join) @@ -80,7 +74,11 @@ namespace TwitchIrcClientTests { Assert.Fail(); } - + } + [TestMethod] + public void TestPart() + { + var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname"; var _part = ReceivedMessage.Parse(PART); Assert.AreEqual(IrcMessageType.PART, _part.MessageType); if (_part is Part part) @@ -92,6 +90,25 @@ namespace TwitchIrcClientTests { Assert.Fail(); } + } + [TestMethod] + public void TestPrivmsg() + { + var PRIVMSG = "@badge-info=subscriber/1;badges=subscriber/0;client-nonce=202e32113a3768963eded865e051fc5b;color=#AAAAFF;" + + "display-name=ChattingUser;emotes=;first-msg=0;flags=;id=24fe75a1-06a5-4078-a31f-cf615107b2a2;mod=0;returning-chatter=0;" + + "room-id=321654987;subscriber=1;tmi-sent-ts=1710920497332;turbo=0;user-id=01234567;user-type= " + + ":chattinguser!chattinguser@chattinguser.tmi.twitch.tv PRIVMSG #channelname :This is a test chat message"; + var CHEER = "@badge-info=subscriber/9;badges=subscriber/9,twitch-recap-2023/1;bits=100;color=#FF0000;display-name=CheeringUser;" + + //I haven't fixed this emote tag after rewriting the message + "emotes=emotesv2_44a39d65e08f43adac871a80e9b96d85:17-24;first-msg=1;flags=;id=5eab1319-5d46-4c55-be29-33c2f834e42e;mod=0;" + + "returning-chatter=0;room-id=321654987;subscriber=0;tmi-sent-ts=1710920826069;turbo=1;user-id=012345678;user-type=;vip " + + ":cheeringuser!cheeringuser@cheeringuser.tmi.twitch.tv PRIVMSG #channelname :This includes a cheer Cheer100"; + var ESCAPE = @"@escaped=\:\s\\\r\n\a\b\c PRIVMSG #channelname :message"; + var EMOTES = @"@badge-info=subscriber/4;badges=subscriber/3;client-nonce=2cc8bb73f5d946b22ec2905c8ccdee7a;color=#1E90FF;" + + @"display-name=Ikatono;emote-only=1;emotes=emotesv2_4f3ee26e385b46aa88d5f45307489939:0-12,14-26/emotesv2_9046ad54f76f42389edb4cc828b1b057" + + @":28-35,37-44;first-msg=0;flags=;id=08424675-217f-44bc-b9c0-24e2e2dd5f33;mod=0;returning-chatter=0;room-id=230151386;" + + @"subscriber=1;tmi-sent-ts=1711136008625;turbo=0;user-id=24866530;user-type= :ikatono!ikatono@ikatono.tmi.twitch.tv " + + @"PRIVMSG #bajiru_en :bajiBUFFERING bajiBUFFERING bajiBONK bajiBONK"; var _priv = ReceivedMessage.Parse(PRIVMSG); Assert.AreEqual(IrcMessageType.PRIVMSG, _priv.MessageType); @@ -111,6 +128,7 @@ namespace TwitchIrcClientTests Assert.AreEqual(UserType.Normal, priv.UserType); Assert.IsFalse(priv.Vip); Assert.AreEqual(new DateTime(2024, 3, 20, 7, 41, 37, 332, DateTimeKind.Utc), priv.Timestamp); + Assert.IsTrue(priv.Badges.SequenceEqual([new Badge("subscriber", "0")])); } else { @@ -134,8 +152,388 @@ namespace TwitchIrcClientTests Assert.AreEqual("012345678", cheer.UserId); Assert.AreEqual(UserType.Normal, cheer.UserType); Assert.IsTrue(cheer.Vip); - //test that timestamp is within 1 second Assert.AreEqual(new DateTime(2024, 3, 20, 7, 47, 6, 069, DateTimeKind.Utc), cheer.Timestamp); + Assert.IsTrue(cheer.Badges.SequenceEqual([ + new Badge("subscriber", "9"), + new Badge("twitch-recap-2023", "1"), + ])); + } + else + { + Assert.Fail(); + } + + var _escape = ReceivedMessage.Parse(ESCAPE); + Assert.AreEqual(IrcMessageType.PRIVMSG, _escape.MessageType); + if (_escape is Privmsg escape) + { + Assert.AreEqual("; \\\r\nabc", escape.MessageTags["escaped"]); + } + else + { + Assert.Fail(); + } + + var _emotes = ReceivedMessage.Parse(EMOTES); + Assert.AreEqual(IrcMessageType.PRIVMSG, _emotes.MessageType); + if (_emotes is Privmsg emotes) + { + Assert.IsTrue(emotes.Emotes.SequenceEqual([ + new Emote("emotesv2_4f3ee26e385b46aa88d5f45307489939", 0, 12-0+1), + new Emote("emotesv2_4f3ee26e385b46aa88d5f45307489939", 14, 26-14+1), + new Emote("emotesv2_9046ad54f76f42389edb4cc828b1b057", 28, 35-28+1), + new Emote("emotesv2_9046ad54f76f42389edb4cc828b1b057", 37, 44-37+1), + ])); + } + else + { + Assert.Fail(); + } + } + [TestMethod] + public void TestUserNotice() + { + //these 4 are examples given from Twitch's USERNOTICE tags page + var RESUB = @"@badge-info=;badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;" + + @"id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-cumulative-months=6;msg-param-streak-months=2;" + + @"msg-param-should-share-streak=1;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=12345678;subscriber=1;" + + @"system-msg=ronni\shas\ssubscribed\sfor\s6\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=87654321;user-type=staff" + + @" :tmi.twitch.tv USERNOTICE #dallas :Great stream -- keep it up!"; + var GIFTED = @"@badge-info=;badges=staff/1,premium/1;color=#0000FF;display-name=TWW2;emotes=;" + + @"id=e9176cd8-5e22-4684-ad40-ce53c2561c5e;login=tww2;mod=0;msg-id=subgift;msg-param-months=1;" + + @"msg-param-recipient-display-name=Mr_Woodchuck;msg-param-recipient-id=55554444;msg-param-recipient-name=mr_woodchuck;" + + @"msg-param-sub-plan-name=House\sof\sNyoro~n;msg-param-sub-plan=1000;room-id=19571752;subscriber=0;" + + @"system-msg=TWW2\sgifted\sa\sTier\s1\ssub\sto\sMr_Woodchuck!;tmi-sent-ts=1521159445153;turbo=0;user-id=87654321;user-type=staff" + + @" :tmi.twitch.tv USERNOTICE #forstycup"; + var RAID = @"@badge-info=;badges=turbo/1;color=#9ACD32;display-name=TestChannel;emotes=;id=3d830f12-795c-447d-af3c-ea05e40fbddb;" + + @"login=testchannel;mod=0;msg-id=raid;msg-param-displayName=TestChannel;msg-param-login=testchannel;msg-param-viewerCount=15;" + + @"room-id=33332222;subscriber=0;system-msg=15\sraiders\sfrom\sTestChannel\shave\sjoined\n!;tmi-sent-ts=1507246572675;turbo=1;" + + @"user-id=123456;user-type= :tmi.twitch.tv USERNOTICE #othertestchannel"; + var NEWCHATTER = @"@badge-info=;badges=;color=;display-name=SevenTest1;emotes=30259:0-6;id=37feed0f-b9c7-4c3a-b475-21c6c6d21c3d;" + + @"login=seventest1;mod=0;msg-id=ritual;msg-param-ritual-name=new_chatter;room-id=87654321;subscriber=0;" + + @"system-msg=Seventoes\sis\snew\shere!;tmi-sent-ts=1508363903826;turbo=0;user-id=77776666;user-type=" + + @" :tmi.twitch.tv USERNOTICE #seventoes :HeyGuys"; + + var _resub = ReceivedMessage.Parse(RESUB); + Assert.AreEqual(IrcMessageType.USERNOTICE, _resub.MessageType); + if (_resub is UserNotice resub) + { + Assert.AreEqual(Color.FromArgb(0, 128, 0), resub.Color); + Assert.AreEqual("ronni", resub.DisplayName); + Assert.AreEqual("db25007f-7a18-43eb-9379-80131e44d633", resub.Id); + Assert.AreEqual("ronni", resub.Login); + Assert.IsFalse(resub.Moderator); + Assert.AreEqual(RitualType.None, resub.RitualType); + Assert.AreEqual(UserNoticeType.resub, resub.UserNoticeType); + Assert.AreEqual(6, resub.TotalMonths); + Assert.AreEqual(2, resub.StreakMonths); + Assert.IsTrue(resub.ShouldShareStreak); + Assert.AreEqual(SubType.Prime, resub.SubPlan); + Assert.AreEqual("Prime", resub.SubPlanName); + Assert.AreEqual("12345678", resub.RoomId); + Assert.IsTrue(resub.Subscriber); + Assert.AreEqual("ronni has subscribed for 6 months!", resub.SystemMessage); + Assert.AreEqual(new DateTime(2017, 10, 5, 23, 36, 12, 675, DateTimeKind.Utc), + resub.Timestamp); + Assert.IsTrue(resub.Turbo); + Assert.AreEqual("87654321", resub.UserId); + Assert.AreEqual(UserType.Staff, resub.UserType); + Assert.AreEqual("dallas", resub.Channel); + Assert.AreEqual("Great stream -- keep it up!", resub.Message); + } + else + { + Assert.Fail(); + } + + var _gifted = ReceivedMessage.Parse(GIFTED); + Assert.AreEqual(IrcMessageType.USERNOTICE, _gifted.MessageType); + if (_gifted is UserNotice gifted) + { + Assert.AreEqual(Color.FromArgb(0, 0, 255), gifted.Color); + Assert.AreEqual("TWW2", gifted.DisplayName); + Assert.AreEqual("e9176cd8-5e22-4684-ad40-ce53c2561c5e", gifted.Id); + Assert.AreEqual("tww2", gifted.Login); + Assert.IsFalse(gifted.Moderator); + Assert.AreEqual(RitualType.None, gifted.RitualType); + Assert.AreEqual(UserNoticeType.subgift, gifted.UserNoticeType); + Assert.AreEqual(1, gifted.TotalMonths); + Assert.AreEqual("Mr_Woodchuck", gifted.RecipientDisplayName); + //Twitch's example uses "msg-param-recipient-name" which doesn't appear anywhere + //else in the documentation. I believe this was inteded to be "msg-param-recipient-user-name" + //Assert.AreEqual("mr_woodchuck", gifted.RecipientUsername); + Assert.AreEqual("55554444", gifted.RecipientId); + Assert.AreEqual("House of Nyoro~n", gifted.SubPlanName); + Assert.AreEqual(SubType.T1, gifted.SubPlan); + Assert.AreEqual("19571752", gifted.RoomId); + Assert.IsFalse(gifted.Subscriber); + Assert.AreEqual("TWW2 gifted a Tier 1 sub to Mr_Woodchuck!", gifted.SystemMessage); + Assert.AreEqual(new DateTime(2018, 3, 16, 0, 17, 25, 153, DateTimeKind.Utc), + gifted.Timestamp); + Assert.IsFalse(gifted.Turbo); + Assert.AreEqual("87654321", gifted.UserId); + Assert.AreEqual(UserType.Staff, gifted.UserType); + Assert.AreEqual("forstycup", gifted.Channel); + } + else + { + Assert.Fail(); + } + + var _raid = ReceivedMessage.Parse(RAID); + Assert.AreEqual(IrcMessageType.USERNOTICE, _raid.MessageType); + if (_raid is UserNotice raid) + { + Assert.AreEqual(Color.FromArgb(154, 205, 50), raid.Color); + Assert.AreEqual("TestChannel", raid.DisplayName); + Assert.AreEqual("3d830f12-795c-447d-af3c-ea05e40fbddb", raid.Id); + Assert.AreEqual("testchannel", raid.Login); + Assert.IsFalse(raid.Moderator); + Assert.AreEqual(RitualType.None, raid.RitualType); + Assert.AreEqual(UserNoticeType.raid, raid.UserNoticeType); + Assert.AreEqual("TestChannel", raid.RaidingChannelDisplayName); + Assert.AreEqual("testchannel", raid.RaidingChannelLogin); + Assert.AreEqual(15, raid.ViewerCount); + Assert.AreEqual("33332222", raid.RoomId); + Assert.IsFalse(raid.Subscriber); + Assert.AreEqual("15 raiders from TestChannel have joined\n!", raid.SystemMessage); + Assert.AreEqual(new DateTime(2017, 10, 5, 23, 36, 12, 675, DateTimeKind.Utc), + raid.Timestamp); + Assert.IsTrue(raid.Turbo); + Assert.AreEqual("123456", raid.UserId); + Assert.AreEqual(UserType.Normal, raid.UserType); + } + else + { + Assert.Fail(); + } + + var _newchatter = ReceivedMessage.Parse(NEWCHATTER); + Assert.AreEqual(IrcMessageType.USERNOTICE, _newchatter.MessageType); + if (_newchatter is UserNotice newchatter) + { + Assert.AreEqual(null, newchatter.Color); + Assert.AreEqual("SevenTest1", newchatter.DisplayName); + Assert.AreEqual("37feed0f-b9c7-4c3a-b475-21c6c6d21c3d", newchatter.Id); + Assert.AreEqual("seventest1", newchatter.Login); + Assert.IsFalse(newchatter.Moderator); + Assert.AreEqual(RitualType.new_chatter, newchatter.RitualType); + Assert.AreEqual("87654321", newchatter.RoomId); + Assert.IsFalse(newchatter.Subscriber); + Assert.AreEqual("Seventoes is new here!", newchatter.SystemMessage); + Assert.AreEqual(new DateTime(2017, 10, 18, 21, 58, 23, 826, DateTimeKind.Utc), + newchatter.Timestamp); + Assert.IsFalse(newchatter.Turbo); + Assert.AreEqual("77776666", newchatter.UserId); + Assert.AreEqual(UserType.Normal, newchatter.UserType); + } + else + { + Assert.Fail(); + } + } + [TestMethod] + public void TestUserstate() + { + var USERSTATE = @"@badge-info=;badges=staff/1;color=#0D4200;display-name=ronni;" + + @"emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;mod=1;subscriber=1;" + + @"turbo=1;user-type=staff :tmi.twitch.tv USERSTATE #dallas"; + + var _userstate = ReceivedMessage.Parse(USERSTATE); + Assert.AreEqual(IrcMessageType.USERSTATE, _userstate.MessageType); + if (_userstate is UserState userstate) + { + Assert.AreEqual("dallas", userstate.Channel); + Assert.AreEqual(Color.FromArgb(13, 66, 0), userstate.Color); + Assert.AreEqual("ronni", userstate.DisplayName); + Assert.IsTrue(userstate.EmoteSets.SequenceEqual([0, 33, 50, 237, 793, 2126, 3517, 4578, 5569, 9400, 10337, 12239])); + Assert.IsTrue(userstate.Moderator); + Assert.IsTrue(userstate.Subscriber); + Assert.IsTrue(userstate.Turbo); + Assert.AreEqual(UserType.Staff, userstate.UserType); + } + else + { + Assert.Fail(); + } + } + [TestMethod] + public void TestWhisper() + { + //Taken from a Twitch documentation example + //https://dev.twitch.tv/docs/irc/tags/#whisper-tags + var WHISPER = @"@badges=staff/1,bits-charity/1;color=#8A2BE2;display-name=PetsgomOO;emotes=;message-id=306;" + + @"thread-id=12345678_87654321;turbo=0;user-id=87654321;user-type=staff" + + @" :petsgomoo!petsgomoo@petsgomoo.tmi.twitch.tv WHISPER foo :hello"; + + var _whisper = ReceivedMessage.Parse(WHISPER); + Assert.AreEqual(IrcMessageType.WHISPER, _whisper.MessageType); + if (_whisper is Whisper whisper) + { + Assert.IsTrue(whisper.Badges.SequenceEqual([ + new Badge("staff", "1"), + new Badge("bits-charity", "1"), + ])); + Assert.AreEqual(Color.FromArgb(138, 43, 226), whisper.Color); + Assert.AreEqual("PetsgomOO", whisper.DisplayName); + Assert.IsTrue(whisper.Emotes.SequenceEqual([])); + Assert.AreEqual("306", whisper.MessageId); + Assert.AreEqual("12345678_87654321", whisper.ThreadId); + Assert.IsFalse(whisper.Turbo); + Assert.AreEqual("87654321", whisper.UserId); + Assert.AreEqual(UserType.Staff, whisper.UserType); + Assert.AreEqual("hello", whisper.Message); + } + else + { + Assert.Fail(); + } + } + [TestMethod] + public void TestGlobalUserState() + { + var GLOBAL = @"@badge-info=subscriber/8;badges=subscriber/6;color=#0D4200;display-name=dallas;" + + @"emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=12345678;" + + @"user-type=admin :tmi.twitch.tv GLOBALUSERSTATE"; + + var _global = ReceivedMessage.Parse(GLOBAL); + Assert.AreEqual(IrcMessageType.GLOBALUSERSTATE, _global.MessageType); + if (_global is GlobalUserState global) + { + Assert.IsTrue(global.BadgeInfo.SequenceEqual(["subscriber/8"])); + Assert.IsTrue(global.Badges.SequenceEqual([new Badge("subscriber", "6")])); + Assert.AreEqual(Color.FromArgb(13, 66, 0), global.Color); + Assert.AreEqual("dallas", global.DisplayName); + Assert.IsTrue(global.EmoteSets.SequenceEqual([ + 0, 33, 50, 237, 793, 2126, 3517, 4578, 5569, 9400, 10337, + 12239])); + Assert.IsFalse(global.Turbo); + Assert.AreEqual("12345678", global.UserId); + Assert.AreEqual(UserType.Admin, global.UserType); + } + else + { + Assert.Fail(); + } + } + [TestMethod] + public void TestClearMsg() + { + var CLEARMSG = @"@login=ronni;room-id=;target-msg-id=abc-123-def;tmi-sent-ts=1642720582342" + + @" :tmi.twitch.tv CLEARMSG #dallas :HeyGuys"; + + var _clearmsg = ReceivedMessage.Parse(CLEARMSG); + Assert.AreEqual(IrcMessageType.CLEARMSG, _clearmsg.MessageType); + if (_clearmsg is ClearMsg clearmsg) + { + Assert.AreEqual("ronni", clearmsg.Login); + Assert.AreEqual("", clearmsg.RoomId); + Assert.AreEqual("abc-123-def", clearmsg.TargetMessageId); + Assert.AreEqual(new DateTime(2022, 1, 20, 23, 16, 22, 342, DateTimeKind.Utc), + clearmsg.Timestamp); + Assert.AreEqual("dallas", clearmsg.Channel); + Assert.AreEqual("HeyGuys", clearmsg.Message); + } + else + { + Assert.Fail(); + } + } + [TestMethod] + public void TestClearChat() + { + var PERMA = @"@room-id=12345678;target-user-id=87654321;tmi-sent-ts=1642715756806" + + @" :tmi.twitch.tv CLEARCHAT #dallas :ronni"; + var CLEARCHAT = @"@room-id=12345678;tmi-sent-ts=1642715695392 :tmi.twitch.tv CLEARCHAT #dallas"; + var TIMEOUT = @"@ban-duration=350;room-id=12345678;target-user-id=87654321;tmi-sent-ts=1642719320727" + + @" :tmi.twitch.tv CLEARCHAT #dallas :ronni"; + + var _perma = ReceivedMessage.Parse(PERMA); + Assert.AreEqual(IrcMessageType.CLEARCHAT, _perma.MessageType); + if (_perma is ClearChat perma) + { + Assert.AreEqual("12345678", perma.RoomId); + Assert.AreEqual("87654321", perma.TargetUserId); + Assert.AreEqual(new DateTime(2022, 1, 20, 21, 55, 56, 806, DateTimeKind.Utc), + perma.Timestamp); + Assert.AreEqual("dallas", perma.Channel); + Assert.AreEqual("ronni", perma.User); + } + else + { + Assert.Fail(); + } + + var _clearchat = ReceivedMessage.Parse(CLEARCHAT); + Assert.AreEqual(IrcMessageType.CLEARCHAT, _clearchat.MessageType); + if (_clearchat is ClearChat clearchat) + { + Assert.AreEqual("12345678", clearchat.RoomId); + Assert.AreEqual(new DateTime(2022, 1, 20, 21, 54, 55, 392), + clearchat.Timestamp); + Assert.AreEqual("dallas", clearchat.Channel); + } + else + { + Assert.Fail(); + } + + var _timeout = ReceivedMessage.Parse(TIMEOUT); + Assert.AreEqual(IrcMessageType.CLEARCHAT, _timeout.MessageType); + if (_timeout is ClearChat timeout) + { + + } + else + { + Assert.Fail(); + } + } + [TestMethod] + public void TestHostTarget() + { + var START = @":tmi.twitch.tv HOSTTARGET #abc :xyz 10"; + var END = @":tmi.twitch.tv HOSTTARGET #abc :- 10"; + //this should be valid based on the Twitch documentation but there + //doesn't seem to be a real use case + var NOCHAN = @":tmi.twitch.tv HOSTTARGET #abc : 10"; + + var _start = ReceivedMessage.Parse(START); + Assert.AreEqual(IrcMessageType.HOSTTARGET, _start.MessageType); + if (_start is HostTarget start) + { + Assert.AreEqual("abc", start.HostingChannel); + Assert.AreEqual("xyz", start.ChannelBeingHosted); + Assert.AreEqual(10, start.NumberOfViewers); + Assert.IsTrue(start.NowHosting); + } + else + { + Assert.Fail(); + } + + var _end = ReceivedMessage.Parse(END); + Assert.AreEqual(IrcMessageType.HOSTTARGET, _end.MessageType); + if (_end is HostTarget end) + { + Assert.AreEqual("abc", end.HostingChannel); + Assert.AreEqual("", end.ChannelBeingHosted); + Assert.IsFalse(end.NowHosting); + Assert.AreEqual(10, end.NumberOfViewers); + } + else + { + Assert.Fail(); + } + + var _nochan = ReceivedMessage.Parse(NOCHAN); + Assert.AreEqual(IrcMessageType.HOSTTARGET, _nochan.MessageType); + if (_nochan is HostTarget nochan) + { + Assert.AreEqual("abc", nochan.HostingChannel); + Assert.AreEqual("", nochan.ChannelBeingHosted); + Assert.IsTrue(nochan.NowHosting); + Assert.AreEqual(10, nochan.NumberOfViewers); } else {