Code/Tikfinity/Protocol/TikfinityEventParser.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
namespace TikfinitySbox;
/// <summary>
/// Normalizes several common TikFinity envelope shapes without trusting dynamic objects.
/// </summary>
public static class TikfinityEventParser
{
public const int MaxIdentityLength = 96;
public const int MaxDisplayLength = 128;
public const int MaxCommentLength = 512;
public const int MaxUrlLength = 1024;
public const int MaxCount = 1_000_000;
static string[] TypeNames => new[] { "eventType", "event_type", "event", "type" };
public static bool TryParseMany( string json, List<TikfinityEvent> output, out string error )
{
error = "";
if ( output is null )
{
error = "Output list is null.";
return false;
}
if ( string.IsNullOrWhiteSpace( json ) )
{
error = "Message is empty.";
return false;
}
var initialCount = output.Count;
try
{
using var document = JsonDocument.Parse( json );
var root = document.RootElement;
if ( root.ValueKind == JsonValueKind.Array )
{
foreach ( var item in root.EnumerateArray() )
TryParseObject( item, default, false, output );
}
else if ( root.ValueKind == JsonValueKind.Object )
{
if ( TryGetProperty( root, "data", out var data ) && data.ValueKind == JsonValueKind.Array )
{
foreach ( var item in data.EnumerateArray() )
TryParseObject( item, root, true, output );
}
else
{
TryParseObject( root, default, false, output );
}
}
else
{
error = "Root JSON value must be an object or array.";
return false;
}
if ( output.Count == initialCount )
{
error = "No event objects were found.";
return false;
}
return true;
}
catch ( JsonException ex )
{
error = Limit( ex.Message, MaxDisplayLength );
return false;
}
catch ( Exception ex )
{
error = Limit( ex.Message, MaxDisplayLength );
return false;
}
}
static void TryParseObject( JsonElement item, JsonElement parent, bool hasParent, List<TikfinityEvent> output )
{
if ( item.ValueKind != JsonValueKind.Object ) return;
var scopes = new List<JsonElement> { item };
AddObjectScope( item, "data", scopes );
AddObjectScope( item, "payload", scopes );
AddObjectScope( item, "eventData", scopes );
if ( hasParent )
{
scopes.Add( parent );
AddObjectScope( parent, "payload", scopes );
}
var type = ReadString( scopes, TypeNames, MaxIdentityLength );
var kind = ParseKind( type );
var userScopes = new List<JsonElement>( scopes );
AddNestedObjectScopes( scopes, "user", userScopes );
AddNestedObjectScopes( scopes, "sender", userScopes );
var giftScopes = new List<JsonElement>( scopes );
AddNestedObjectScopes( scopes, "gift", giftScopes );
AddNestedObjectScopes( scopes, "extendedGiftInfo", giftScopes );
var repeatCount = ClampCount( ReadInt( scopes, new[] { "repeatCount", "repeat_count", "repeat", "count" }, 1 ) );
if ( repeatCount < 1 ) repeatCount = 1;
var giftCoins = ClampCount( ReadInt( giftScopes, new[] { "diamondCount", "diamond_count", "coins", "coinValue", "value" }, 0 ) );
var totalCoins = ClampCount( ReadInt( scopes, new[] { "totalDiamonds", "total_diamonds", "totalCoins", "total_coins", "diamondCost" }, 0 ) );
if ( totalCoins <= 0 && giftCoins > 0 )
totalCoins = ClampLongToCount( (long)giftCoins * repeatCount );
var hasRepeatEnd = TryReadBool( scopes, new[] { "repeatEnd", "repeat_end", "isFinal", "streakEnded" }, out var repeatEnd );
var giftType = ReadInt( giftScopes, new[] { "giftType", "gift_type", "type" }, 0 );
var isStreakable = ReadBool( giftScopes, new[] { "streakable", "isStreakable" }, giftType == 1 );
output.Add( new TikfinityEvent
{
Kind = kind,
RawType = Limit( type, MaxIdentityLength ),
EventId = ReadString( scopes, new[] { "eventId", "event_id", "msgId", "messageId", "id" }, MaxIdentityLength ),
TimestampMilliseconds = ReadLong( scopes, new[] { "timestamp", "timestampMs", "createTime", "createdAt" }, 0L ),
UserId = ReadString( userScopes, new[] { "userId", "user_id", "id" }, MaxIdentityLength ),
Username = ReadString( userScopes, new[] { "uniqueId", "unique_id", "username", "userName" }, MaxIdentityLength ),
Nickname = ReadString( userScopes, new[] { "nickname", "displayName", "display_name" }, MaxDisplayLength ),
ProfilePictureUrl = ReadString( userScopes, new[] { "profilePictureUrl", "profilePicture", "profile_picture_url", "avatarUrl" }, MaxUrlLength ),
GiftId = ReadString( giftScopes, new[] { "giftId", "gift_id", "id" }, MaxIdentityLength ),
GiftName = ReadString( giftScopes, new[] { "giftName", "gift_name", "name" }, MaxDisplayLength ),
GiftCoins = giftCoins,
TotalCoins = totalCoins,
RepeatCount = repeatCount,
IsStreakable = isStreakable,
HasRepeatEnd = hasRepeatEnd,
RepeatEnd = repeatEnd,
Comment = ReadString( scopes, new[] { "comment", "commentText", "comment_text", "text", "message" }, MaxCommentLength ),
LikeCount = ClampCount( ReadInt( scopes, new[] { "likeCount", "like_count", "likes", "count" }, 0 ) ),
ViewerCount = ClampCount( ReadInt( scopes, new[] { "viewerCount", "viewer_count", "viewers", "count" }, 0 ) )
} );
}
static TikfinityEventKind ParseKind( string type )
{
var normalized = (type ?? "").Trim().ToLowerInvariant();
return normalized switch
{
"gift" or "gifts" => TikfinityEventKind.Gift,
"chat" or "comment" or "comments" => TikfinityEventKind.Comment,
"like" or "likes" => TikfinityEventKind.Like,
"follow" or "follower" => TikfinityEventKind.Follow,
"share" => TikfinityEventKind.Share,
"subscribe" or "subscription" or "sub" => TikfinityEventKind.Subscribe,
"join" or "member" => TikfinityEventKind.Join,
"viewer" or "viewer_update" or "roomuser" => TikfinityEventKind.ViewerUpdate,
"streamend" or "stream_end" or "liveend" => TikfinityEventKind.StreamEnd,
_ => TikfinityEventKind.Unknown
};
}
static void AddObjectScope( JsonElement source, string name, List<JsonElement> target )
{
if ( TryGetProperty( source, name, out var value ) && value.ValueKind == JsonValueKind.Object )
target.Add( value );
}
static void AddNestedObjectScopes( List<JsonElement> sources, string name, List<JsonElement> target )
{
var count = sources.Count;
for ( var i = 0; i < count; i++ )
{
if ( TryGetProperty( sources[i], name, out var value ) && value.ValueKind == JsonValueKind.Object )
target.Insert( 0, value );
}
}
static bool TryGetProperty( JsonElement source, string name, out JsonElement value )
{
value = default;
if ( source.ValueKind != JsonValueKind.Object ) return false;
if ( source.TryGetProperty( name, out value ) ) return true;
foreach ( var property in source.EnumerateObject() )
{
if ( string.Equals( property.Name, name, StringComparison.OrdinalIgnoreCase ) )
{
value = property.Value;
return true;
}
}
return false;
}
static bool TryFind( List<JsonElement> scopes, string[] names, out JsonElement value )
{
foreach ( var scope in scopes )
{
foreach ( var name in names )
{
if ( TryGetProperty( scope, name, out value ) ) return true;
}
}
value = default;
return false;
}
static string ReadString( List<JsonElement> scopes, string[] names, int maxLength )
{
if ( !TryFind( scopes, names, out var value ) ) return "";
string result;
switch ( value.ValueKind )
{
case JsonValueKind.String:
result = value.GetString() ?? "";
break;
case JsonValueKind.Number:
case JsonValueKind.True:
case JsonValueKind.False:
result = value.ToString();
break;
default:
return "";
}
return Limit( result.Trim(), maxLength );
}
static int ReadInt( List<JsonElement> scopes, string[] names, int fallback )
{
if ( !TryFind( scopes, names, out var value ) ) return fallback;
if ( value.ValueKind == JsonValueKind.Number && value.TryGetInt32( out var number ) ) return number;
if ( value.ValueKind == JsonValueKind.String && int.TryParse( value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number ) ) return number;
return fallback;
}
static long ReadLong( List<JsonElement> scopes, string[] names, long fallback )
{
if ( !TryFind( scopes, names, out var value ) ) return fallback;
if ( value.ValueKind == JsonValueKind.Number && value.TryGetInt64( out var number ) ) return number;
if ( value.ValueKind == JsonValueKind.String && long.TryParse( value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number ) ) return number;
return fallback;
}
static bool ReadBool( List<JsonElement> scopes, string[] names, bool fallback )
=> TryReadBool( scopes, names, out var value ) ? value : fallback;
static bool TryReadBool( List<JsonElement> scopes, string[] names, out bool result )
{
result = false;
if ( !TryFind( scopes, names, out var value ) ) return false;
if ( value.ValueKind == JsonValueKind.True )
{
result = true;
return true;
}
if ( value.ValueKind == JsonValueKind.False )
{
result = false;
return true;
}
if ( value.ValueKind == JsonValueKind.Number && value.TryGetInt32( out var number ) )
{
result = number != 0;
return true;
}
if ( value.ValueKind == JsonValueKind.String )
{
var text = value.GetString();
if ( bool.TryParse( text, out result ) ) return true;
if ( int.TryParse( text, out number ) )
{
result = number != 0;
return true;
}
}
return false;
}
static int ClampCount( int value ) => value < 0 ? 0 : value > MaxCount ? MaxCount : value;
static int ClampLongToCount( long value ) => value < 0L ? 0 : value > MaxCount ? MaxCount : (int)value;
static string Limit( string value, int maxLength )
{
if ( string.IsNullOrEmpty( value ) ) return "";
if ( maxLength < 1 ) return "";
return value.Length <= maxLength ? value : value.Substring( 0, maxLength );
}
}