diff --git a/BuildNumber.txt b/BuildNumber.txt index 1cb1a4d..102ae24 100644 --- a/BuildNumber.txt +++ b/BuildNumber.txt @@ -1 +1 @@ -1988 +2105 diff --git a/BuildVersion.txt b/BuildVersion.txt new file mode 100644 index 0000000..28b8ef6 --- /dev/null +++ b/BuildVersion.txt @@ -0,0 +1 @@ +1.1.2 \ No newline at end of file diff --git a/README.md b/README.md index 3d36f71..2812ef8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ Tailgrab will read the VRChat Local Game Log files in real time, parse them for - Group Flagging based on user directed database. - Historical tracking of User elapsed time seen from your usage of the application. - Trigger actions based on VRChat log events of "Vote To Kick" or "Group Moderation Action (Kick/Warn)", such as sending OSC Avatar Parameters, sending keystrokes to other applications, etc. +- Allows capture of Groups and Avatars that are not currently in the instance for future alerting or ignoring them +- Imports Moderated Users avatars as "Nuisance" level for quick identification in future instances. +- Saves Layout of window size, position, columns and splitters for next open. ## Installation @@ -214,4 +217,6 @@ https://gist.githubusercontent.com/jlong23/2b051df849cabb4da273eaf98225ae4e/raw/ [Config Tab, Alerts](./docs/Config_Alerts.md) Configure Alert Levels sounds and highlight colors. +[Config Tab, Open Logs](./docs/Config_OpenLogs.md) Show and close open logs. + [Config Tab, Migrations](./docs/Config_Migrations.md) Migrate V1.0.9 Database to the new database. diff --git a/docs/Config_OpenLogs.md b/docs/Config_OpenLogs.md new file mode 100644 index 0000000..049fcc9 --- /dev/null +++ b/docs/Config_OpenLogs.md @@ -0,0 +1,6 @@ +[Back](../README.md) +# Open Logs + +New to V1.1.2; To help users to understand what logs are being used by TailGrab what has activity (Creation Date, Last Update and Lines Processed). The action button can be used to close unused logs; especialy when you open and close VR Chat very often due to crashers or VR Chat's poor architecture that makes it unstable. + +[](./tailgrab_tab_config_migration_complete.png) \ No newline at end of file diff --git a/docs/tailgrab_tab_config_openlogs.png b/docs/tailgrab_tab_config_openlogs.png new file mode 100644 index 0000000..93b8a36 Binary files /dev/null and b/docs/tailgrab_tab_config_openlogs.png differ diff --git a/src/AvatarManagement/AvatarManagement.cs b/src/AvatarManagement/AvatarManagement.cs deleted file mode 100644 index 321a90b..0000000 --- a/src/AvatarManagement/AvatarManagement.cs +++ /dev/null @@ -1,489 +0,0 @@ -using ConcurrentPriorityQueue.Core; -using Microsoft.EntityFrameworkCore; -using NLog; -using Polly; -using Tailgrab.Clients.Ollama; -using Tailgrab.Common; -using Tailgrab.Models; -using VRChat.API.Model; - -namespace Tailgrab.AvatarManagement -{ - public class AvatarManagementService - { - public static Logger logger = LogManager.GetCurrentClassLogger(); - - private ServiceRegistry _serviceRegistry; - - private ConcurrentPriorityQueue, int> priorityQueue = new ConcurrentPriorityQueue, int>(); - private Dictionary recentlyProcessedAvatars = new Dictionary(); - - public int GetQueueCount() - { - return priorityQueue.Count; - } - - public AvatarManagementService(ServiceRegistry serviceRegistry) - { - _serviceRegistry = serviceRegistry; - - _ = Task.Run(() => AvatarCheckTask(priorityQueue, _serviceRegistry)); - - } - - public void AddAvatar(AvatarInfo avatar) - { - try - { - _serviceRegistry.GetDBContext().AvatarInfos.Add(avatar); - _serviceRegistry.GetDBContext().SaveChanges(); - } - catch (Exception ex) - { - logger.Error($"Error creating avatar: {ex.Message}"); - } - } - - public AvatarInfo? GetAvatarById(string avatarId) - { - return _serviceRegistry.GetDBContext().AvatarInfos.Find(avatarId); - } - - public void UpdateAvatar(AvatarInfo avatar) - { - try - { - avatar.UpdatedAt = DateTime.UtcNow; - _serviceRegistry.GetDBContext().AvatarInfos.Update(avatar); - _serviceRegistry.GetDBContext().SaveChanges(); - } - catch (Exception ex) - { - logger.Error($"Error updating avatar: {ex.Message}"); - } - } - - public void DeleteAvatar(string avatarId) - { - var avatar = _serviceRegistry.GetDBContext().AvatarInfos.Find(avatarId); - if (avatar != null) - { - _serviceRegistry.GetDBContext().AvatarInfos.Remove(avatar); - _serviceRegistry.GetDBContext().SaveChanges(); - } - } - - public void CacheAvatars(List avatarIdInCache) - { - TailgrabDBContext dbContext = _serviceRegistry.GetDBContext(); - foreach (var avatarId in avatarIdInCache) - { - EnqueueAvatarForCheck(avatarId); - } - } - - private void EnqueueAvatarForCheck(string avatarId) - { - if (recentlyProcessedAvatars.TryGetValue(avatarId, out DateTime dateTime)) - { - if ((DateTime.UtcNow - dateTime).TotalMinutes < 60) - { - return; - } - } - recentlyProcessedAvatars.Add(avatarId, DateTime.UtcNow); - - var queuedItem = new QueuedAvatarProcess(5, avatarId); - - priorityQueue.Enqueue(queuedItem); - } - - public void EnqueueWatchAvatarForCheck(QueuedAvatarWatch watch) - { - priorityQueue.Enqueue(watch); - } - - public void EnqueueModeratedAvatarForCheck(QueuedModeratedAvatarWatch watch) - { - priorityQueue.Enqueue(watch); - } - - - public void GetAvatarsFromUser(string userId, string avatarName) - { - - logger.Debug($"Fetching avatars for user {userId} to find avatar named {avatarName}"); - - try - { - // Avatar already exists in the database and was updated within the last 12 hours - System.Threading.Thread.Sleep(500); - List avatarData = _serviceRegistry.GetVRChatAPIClient().GetAvatarsByUserId(userId); - foreach (var avatar in avatarData) - { - logger.Debug(avatar.ToString()); - if (avatar.Name.Equals(avatarName, StringComparison.OrdinalIgnoreCase)) - { - AvatarInfo? dbAvatarInfo = GetAvatarById(avatar.Id); - - if (dbAvatarInfo == null) - { - var avatarInfo = new AvatarInfo - { - AvatarId = avatar.Id, - UserId = avatar.AuthorId, - AvatarName = avatar.Name, - ImageUrl = avatar.ImageUrl, - CreatedAt = avatar.CreatedAt, - UpdatedAt = DateTime.UtcNow, - AlertType = AlertTypeEnum.None, - UserName = avatar.AuthorName - }; - - AddAvatar(avatarInfo); - } - else - { - dbAvatarInfo.UserId = avatar.AuthorId; - dbAvatarInfo.UserName = avatar.AuthorName; - dbAvatarInfo.AvatarName = avatar.Name; - dbAvatarInfo.ImageUrl = avatar.ImageUrl; - dbAvatarInfo.CreatedAt = avatar.CreatedAt; - UpdateAvatar(dbAvatarInfo); - } - } - } - } - catch (Exception ex) - { - logger.Error($"Error fetching avatar: {ex.Message}"); - } - } - - public void CompactDatabase() - { - _serviceRegistry.GetDBContext().Database.ExecuteSqlRaw("VACUUM;"); - } - - internal AvatarInfo? CheckAvatarByName(string avatarName) - { - var bannedAvatars = _serviceRegistry.GetDBContext().AvatarInfos - .Where(b => b.AvatarName != null && b.AvatarName.Equals(avatarName) && b.AlertType > 0) - .OrderByDescending(b => b.AlertType) - .ToList(); - - if (bannedAvatars.Count > 0) - { - // Play alert sound based on the highest alert type found for the avatar - AlertTypeEnum maxAlertType = bannedAvatars[0].AlertType; - SoundManager.PlayAlertSound(CommonConst.Avatar_Alert_Key, maxAlertType); - - return bannedAvatars[0]; - } - - return null; - } - - public static async Task AvatarCheckTask(ConcurrentPriorityQueue, int> priorityQueue, ServiceRegistry serviceRegistry) - { - OllamaClient.logger.Info($"Amplitude Avatar Cache Queue Running"); - TailgrabDBContext dBContext = serviceRegistry.GetDBContext(); - while (true) - { - // Process items from the priority queue - while (true) - { - var result = priorityQueue.Dequeue(); - if( result.IsSuccess ) - { - if (result.Value is QueuedAvatarProcess item && item.AvatarId != null) - { - await UpdateAmpAvatarRecord(serviceRegistry, dBContext, item.AvatarId); - } - else if (result.Value is QueuedAvatarWatch item2) - { - await UpdateWatchedAvatarRecord(serviceRegistry, dBContext, item2); - } - else if (result.Value is QueuedModeratedAvatarWatch item3) - { - await UpdateModeratedAvatarRecord(serviceRegistry, dBContext, item3); - } - } - else - { - // No more items to process - break; - } - } - // Wait for a short period before checking the queue again - await Task.Delay(5000); - } - } - - private static async Task UpdateAmpAvatarRecord(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, string avatarId) - { - try - { - AvatarInfo? dbAvatarInfo = dBContext.AvatarInfos.Find(avatarId); - bool updateNeeded = false; - if (dbAvatarInfo == null) - { - updateNeeded = true; - } - else if (dbAvatarInfo.AlertType == AlertTypeEnum.None && - (!dbAvatarInfo.UpdatedAt.HasValue || dbAvatarInfo.UpdatedAt.Value >= DateTime.UtcNow.AddHours(-2))) - { - updateNeeded = true; - } - - if (updateNeeded) - { - // Adds and Updates avatar info in the database, if it doesn't exist or was last updated more than 2 hours ago - Avatar? avatarData = FetchUpdateAvatarData(serviceRegistry, dBContext, avatarId, dbAvatarInfo); - - if (avatarData == null && dbAvatarInfo == null) - { - // Private Avatar - CreateAvatarInfoForPrivate(dBContext, avatarId); - } - - // Wait for a short period before checking the queue again - await Task.Delay(1000); - } - } - catch (Exception ex) - { - logger.Error(ex, $"Error fetching user profile for userId: {avatarId}"); - } - } - - - private static async Task UpdateModeratedAvatarRecord(ServiceRegistry _serviceRegistry, TailgrabDBContext dbContext, QueuedModeratedAvatarWatch watch) - { - try - { - // Fetch the AvatarInfo record - AvatarInfo? avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); - AvatarManagementService.FetchUpdateAvatarData(_serviceRegistry, dbContext, watch.AvatarId, avatarInfo); - avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); - - if (avatarInfo == null) - { - logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' not found in database/vrc, skipping."); - } - else if (avatarInfo.AlertType == AlertTypeEnum.None) - { - avatarInfo.AlertType = AlertTypeEnum.Nuisance; - avatarInfo.UpdatedAt = DateTime.UtcNow; - dbContext.AvatarInfos.Update(avatarInfo); - dbContext.SaveChanges(); - } - else - { - logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' already has Has an Alert, skipping."); - } - } - catch (Exception ex) - { - logger.Error(ex, $"Line {watch.LineNumber}: Error processing avatar ID '{watch.AvatarId}'"); - } - - // Throttle processing to avoid overwhelming the API - await Task.Delay(1000); - } - - private static async Task UpdateWatchedAvatarRecord(ServiceRegistry _serviceRegistry, TailgrabDBContext dbContext, QueuedAvatarWatch watch) - { - try - { - // Fetch the AvatarInfo record - AvatarInfo? avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); - AvatarManagementService.FetchUpdateAvatarData(_serviceRegistry, dbContext, watch.AvatarId, avatarInfo); - avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); - - if (avatarInfo == null) - { - logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' not found in database/vrc, skipping."); - } - else if (avatarInfo.AlertType == AlertTypeEnum.None) - { - - avatarInfo.AlertType = watch.AlertType; - avatarInfo.UpdatedAt = DateTime.UtcNow; - dbContext.AvatarInfos.Update(avatarInfo); - dbContext.SaveChanges(); - - if (avatarInfo.AlertType >= AlertTypeEnum.Nuisance) - { - await _serviceRegistry.GetVRChatAPIClient().BlockAvatarGlobal(avatarInfo.AvatarId); - } - else - { - await _serviceRegistry.GetVRChatAPIClient().DeleteAvatarGlobal(avatarInfo.AvatarId); - } - - logger.Debug($"Line {watch.LineNumber}: Set Watch State for Avatar ID '{watch.AvatarId}'"); - - } - else - { - logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' already has Has an Alert, skipping."); - } - } - catch (Exception ex) - { - logger.Error(ex, $"Line {watch.LineNumber}: Error processing avatar ID '{watch.AvatarId}'"); - } - - // Throttle processing to avoid overwhelming the API - await Task.Delay(3000); - } - - - private static void CreateAvatarInfoForPrivate(TailgrabDBContext dBContext, string AvatarId) - { - var avatarInfo = new AvatarInfo - { - AvatarId = AvatarId, - UserId = "", - AvatarName = $"Unknown Avatar {AvatarId}", - ImageUrl = "", - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - try - { - dBContext.Add(avatarInfo); - dBContext.SaveChanges(); - logger.Debug($"Adding fallback avatar record for {avatarInfo.ToString()}"); - } - catch (Exception ex) - { - logger.Error($"Error adding fallback avatar record for {AvatarId}: {ex.Message}"); - } - } - - public static Avatar? FetchUpdateAvatarData(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, string AvatarId, AvatarInfo? dbAvatarInfo) - { - Avatar? avatarData = null; - try - { - // Avatar already exists in the database and was updated within the last 12 hours - System.Threading.Thread.Sleep(500); - avatarData = serviceRegistry.GetVRChatAPIClient().GetAvatarById(AvatarId); - if (avatarData != null) - { - if (dbAvatarInfo == null) - { - var avatarInfo = new AvatarInfo - { - AvatarId = avatarData.Id, - UserId = avatarData.AuthorId, - UserName = avatarData.AuthorName, - AvatarName = avatarData.Name, - ImageUrl = avatarData.ImageUrl, - CreatedAt = avatarData.CreatedAt, - UpdatedAt = DateTime.UtcNow - }; - - try - { - dBContext.Add(avatarInfo); - dBContext.SaveChanges(); - } - catch (Exception ex) - { - logger.Error($"Error adding avatar record for {AvatarId}: {ex.Message}"); - } - } - else - { - // Ensure entity is attached to the dbContext before updating to avoid Detached state errors - var entry = dBContext.Entry(dbAvatarInfo); - if (entry.State == Microsoft.EntityFrameworkCore.EntityState.Detached) - { - dBContext.Attach(dbAvatarInfo); - entry = dBContext.Entry(dbAvatarInfo); - } - - dbAvatarInfo.UserId = avatarData.AuthorId; - dbAvatarInfo.UserName = avatarData.AuthorName; - dbAvatarInfo.AvatarName = avatarData.Name; - dbAvatarInfo.ImageUrl = avatarData.ImageUrl; - dbAvatarInfo.CreatedAt = avatarData.CreatedAt; - dbAvatarInfo.UpdatedAt = DateTime.UtcNow; - - try - { - entry.State = Microsoft.EntityFrameworkCore.EntityState.Modified; - dBContext.SaveChanges(); - } - catch (Exception ex) - { - logger.Error($"Error updating avatar record for {AvatarId}: {ex.Message}"); - } - } - } - } - catch (Exception ex) - { - logger.Error($"Error fetching avatar: {ex.Message}"); - } - - return avatarData; - } - } - - internal class QueuedAvatarProcess : IHavePriority - { - public QueuedAvatarProcess(int priority, string avatarId) - { - Priority = priority; - AvatarId = avatarId; - } - - public int Priority { get; set; } - - public string AvatarId { get; set; } - } - - - public class QueuedAvatarWatch : IHavePriority - { - public QueuedAvatarWatch( int priority, string avatarId, AlertTypeEnum alertType, int lineNumber) - { - Priority = priority; - AvatarId = avatarId; - AlertType = alertType; - LineNumber = lineNumber; - } - - public int Priority { get; set; } - - public string AvatarId { get; set; } - - public AlertTypeEnum AlertType { get; set; } - - public int LineNumber { get; set; } - } - - public class QueuedModeratedAvatarWatch : IHavePriority - { - public QueuedModeratedAvatarWatch(int priority, string avatarId, AlertTypeEnum alertType, int lineNumber) - { - Priority = priority; - AvatarId = avatarId; - AlertType = alertType; - LineNumber = lineNumber; - } - - public int Priority { get; set; } - - public string AvatarId { get; set; } - - public AlertTypeEnum AlertType { get; set; } - - public int LineNumber { get; set; } - } -} diff --git a/src/Clients/Ollama/Ollama.cs b/src/Clients/Ollama/Ollama.cs index b6e58d4..aa88265 100644 --- a/src/Clients/Ollama/Ollama.cs +++ b/src/Clients/Ollama/Ollama.cs @@ -146,7 +146,7 @@ public static async Task ProfileCheckTask(ConcurrentPriorityQueue GetUserGroupInformation(ServiceRegistry serviceR { bool saveGroups = ConfigStore.GetStoredKeyBool(CommonConst.Registry_Discovered_Group_Caching, true); logger.Debug($"Processing User Group subscription for userId: {item.UserId}"); - Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId ?? string.Empty); + Player? player = PlayerManager.GetPlayerByUserId(item.UserId ?? string.Empty); if (player != null) { AlertTypeEnum maxAlertType = AlertTypeEnum.None; @@ -201,7 +201,7 @@ private async static Task GetUserGroupInformation(ServiceRegistry serviceR if (groupInfo.AlertType > AlertTypeEnum.None) { - player = serviceRegistry.GetPlayerManager().AddPlayerEventByUserId(item.UserId ?? string.Empty, PlayerEvent.EventType.GroupWatch, $"User is member of group: {groupInfo.GroupName} with alert level {groupInfo.AlertType}"); + player = PlayerManager.AddPlayerEventByUserId(item.UserId ?? string.Empty, PlayerEvent.EventType.GroupWatch, $"User is member of group: {groupInfo.GroupName} with alert level {groupInfo.AlertType}"); player?.AddAlertMessage(AlertClassEnum.Group, groupInfo.AlertType, groupInfo.GroupName); maxAlertType = maxAlertType < groupInfo.AlertType ? groupInfo.AlertType : maxAlertType; } @@ -245,7 +245,7 @@ await ollamaApi.GenerateAsync(request).StreamToEndAsync(responseTask => dBContext.Add(evaluation); dBContext.SaveChanges(); - Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId ?? string.Empty); + Player? player = PlayerManager.GetPlayerByUserId(item.UserId ?? string.Empty); if (player != null) { player.UserBio = item.UserBio; @@ -270,7 +270,7 @@ private static void GetEvaluationFromStore(ServiceRegistry serviceRegistry, Prof if (item != null && item.UserId != null) { - Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId); + Player? player = PlayerManager.GetPlayerByUserId(item.UserId); if (player != null) { player.AIEval = System.Text.Encoding.UTF8.GetString(evaluated.Evaluation); @@ -300,7 +300,7 @@ private static void ProfileViewUpdate(ServiceRegistry serviceRegistry, Player pl { switch (profileWatch) { - case "Harrassment & Bullying": + case "Harassment & Bullying": player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Nuisance, "Hate"); SoundManager.PlayAlertSound(CommonConst.Profile_Alert_Key, AlertTypeEnum.Nuisance); break; @@ -314,10 +314,10 @@ private static void ProfileViewUpdate(ServiceRegistry serviceRegistry, Player pl break; } - serviceRegistry.GetPlayerManager().AddPlayerEventByUserId(player.UserId ?? string.Empty, + PlayerManager.AddPlayerEventByUserId(player.UserId ?? string.Empty, PlayerEvent.EventType.ProfileWatch, $"User profile was flagged by the AI : {profileWatch}"); } - serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); + PlayerManager.OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); } private static string? EvaluateProfile(string? profileText) @@ -331,9 +331,9 @@ private static void ProfileViewUpdate(ServiceRegistry serviceRegistry, Player pl { return "Explicit Sexual"; } - else if (CheckLines(profileText, "Harrassment & Bullying")) + else if (CheckLines(profileText, "Harassment & Bullying")) { - return "Harrassment & Bullying"; + return "Harassment & Bullying"; } else if (CheckLines(profileText, "Self Harm")) { diff --git a/src/Clients/VRChat/VRChat.cs b/src/Clients/VRChat/VRChat.cs index dfafd88..5914626 100644 --- a/src/Clients/VRChat/VRChat.cs +++ b/src/Clients/VRChat/VRChat.cs @@ -10,17 +10,20 @@ using VRChat.API.Client; using VRChat.API.Model; - namespace Tailgrab.Clients.VRChat { public class VRChatClient { private const string URI_VRC_BASE_API = "https://api.vrchat.cloud"; - public static string UserAgent = "Tailgrab/1.1.0"; - public static Logger logger = LogManager.GetCurrentClassLogger(); + private const string APP_NAME = "Tailgrab"; + private const string API_VERSION = "1.1.2"; + private const string APP_CONTACT = "jlong@rabbitearsvideoproduction.com"; + private const string UserAgent = $"{APP_NAME}/{API_VERSION}"; + private static Logger logger = LogManager.GetCurrentClassLogger(); private IVRChat? _vrchat; + #region Authentication public async Task Initialize() { string? username = ConfigStore.LoadSecret(CommonConst.Registry_VRChat_Web_UserName); @@ -37,127 +40,175 @@ public async Task Initialize() return; } - //string cookiePath = Path.Combine(Directory.GetCurrentDirectory(), "cookies.json"); - string cookiePath = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "cookies.json"); - - // Try to load cookies from disk and use them if they are present and not expired - List? loadedCookies = LoadValidCookiesFromFile(cookiePath); - - VRChatClientBuilder builder = new VRChatClientBuilder() - .WithApplication(name: "Tailgrab", version: "1.1.0", contact: "jlong@rabbitearsvideoproduction.com"); - - if (loadedCookies != null && loadedCookies.Count > 0) + if (LoginVRChat() && _vrchat != null) { - logger.Info("Loaded valid cookies from disk, attempting to use them for authentication..."); - //// Try to call WithCookies via reflection (some SDKs expose it) - //var withCookiesMethod = builder.GetType() - // .GetMethods() - // .FirstOrDefault(m => m.Name == "WithCookies" && m.GetParameters().Length == 1); - - //if (withCookiesMethod != null) - //{ - // var result = withCookiesMethod.Invoke(builder, new object[] { loadedCookies }); - // if (result is VRChatClientBuilder cb) - // { - // builder = cb; - // } - // else - // { - // // fallback to username/password if return type not expected - // builder = builder.WithUsername(username).WithPassword(password); - // } - //} - //else - //{ - // // no WithCookies method; fall back to username/password - // builder = builder.WithUsername(username).WithPassword(password); - //} - - string authCookieValue = string.Empty; - string twoFactorCookieValue = string.Empty; - foreach (var cookie in loadedCookies) + var response = await _vrchat.Authentication.GetCurrentUserAsync(); + if (response != null && response is not null) { - if (cookie.Name == "auth") + if (response.RequiresTwoFactorAuth != null && response.RequiresTwoFactorAuth.Contains("emailOtp")) { - authCookieValue = cookie.Value; + logger.Warn("An verification code was sent to your email address!"); + logger.Warn("Prompt user for code: "); + + string code = Microsoft.VisualBasic.Interaction.InputBox("Please enter EMail OTP code (6 digits)"); + var otpResponse = await _vrchat.Authentication.Verify2FAEmailCodeAsync(new TwoFactorEmailCode(code)); } - else if (cookie.Name == "twoFactorAuth") + else if (response.RequiresTwoFactorAuth != null && response.RequiresTwoFactorAuth.Contains("totp")) { - twoFactorCookieValue = cookie.Value; + var totp = new Totp(Base32Encoding.ToBytes(twoFactorSecret)); + string code = totp.ComputeTotp(); + + var otpResponse = await _vrchat.Authentication.Verify2FAAsync(new TwoFactorAuthCode(code)); } + + var currentUser = await _vrchat.Authentication.GetCurrentUserAsync(); + logger.Info($"Logged in as \"{currentUser.DisplayName}\""); + + var cookies = _vrchat.GetCookies(); + PersistCookies(cookies); } - _vrchat = builder.WithAuthCookie(authCookieValue, twoFactorCookieValue).Build(); - } - else + } else { - logger.Info("No valid cookies found on disk, falling back to username/password authentication."); - _vrchat = builder.WithUsername(username).WithPassword(password).Build(); + logger.Warn("Unable to login to VRChat "); + System.Windows.MessageBox.Show("VR Chat Web API failed to log in, check the log file and restart Tailgrab.", "Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + return; } + } + catch (Exception ex) + { + logger.Error(ex, $"Failed to Log Into VRC and to save cookies': {ex.Message}"); + System.Windows.MessageBox.Show($"Failed to Log Into VRChat Web API, check logs for details. Error: {ex.Message}", "Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + } + } + + private bool LoginVRChat() + { + // Try to load cookies from disk and use them if they are present and not expired + List? loadedCookies = LoadCookies(); - var response = await _vrchat.Authentication.GetCurrentUserAsync(); - if (response != null && response is CurrentUser ) + VRChatClientBuilder builder = new VRChatClientBuilder() + .WithApplication(name: APP_NAME, version: API_VERSION, contact: APP_CONTACT); + + if (loadedCookies != null && loadedCookies.Count > 0) + { + logger.Info("Loaded valid cookies from disk, attempting to use them for authentication..."); + + string authCookieValue = string.Empty; + string twoFactorCookieValue = string.Empty; + foreach (var cookie in loadedCookies) { - if (response.RequiresTwoFactorAuth != null && response.RequiresTwoFactorAuth.Contains("emailOtp")) + if (cookie.Name == "auth") { - logger.Info("An verification code was sent to your email address!"); - logger.Info("Enter code: "); - //string code = Console.ReadLine(); - string code = "1234"; - var otpResponse = await _vrchat.Authentication.Verify2FAEmailCodeAsync(new TwoFactorEmailCode(code)); + authCookieValue = cookie.Value; } - else if (response.RequiresTwoFactorAuth != null && response.RequiresTwoFactorAuth.Contains("totp")) + else if (cookie.Name == "twoFactorAuth") { - var totp = new Totp(Base32Encoding.ToBytes(twoFactorSecret)); - string code = totp.ComputeTotp(); - - var otpResponse = await _vrchat.Authentication.Verify2FAAsync(new TwoFactorAuthCode(code)); + twoFactorCookieValue = cookie.Value; } + } + _vrchat = builder.WithAuthCookie(authCookieValue, twoFactorCookieValue).Build(); + return true; + } + else + { + string? username = ConfigStore.LoadSecret(CommonConst.Registry_VRChat_Web_UserName); + string? password = ConfigStore.LoadSecret(CommonConst.Registry_VRChat_Web_Password); + if (username != null && password != null) + { + logger.Info("No valid cookies found on disk, falling back to username/password authentication."); + _vrchat = builder.WithUsername(username).WithPassword(password).Build(); + return true; + } + } + + return false; + } - var currentUser = await _vrchat.Authentication.GetCurrentUserAsync(); - logger.Info($"Logged in as \"{currentUser.DisplayName}\""); - var cookies = _vrchat.GetCookies(); + #endregion - SaveCookiesToFile(cookiePath, cookies); + public List GetAvatarModerations() + { + List moderations = []; + try + { + if (_vrchat != null) + { + moderations = _vrchat.Authentication.GetGlobalAvatarModerations(); } } catch (Exception ex) { - logger.Error(ex, $"Failed to Log Into VRC and to save cookies': {ex.Message}"); - System.Windows.MessageBox.Show($"Failed to Log Into VRChat Web API, check logs for details. Error: {ex.Message}", "Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + logger.Error($"Error fetching avatar moderations: {ex.Message}"); } + return moderations; } - private static List? LoadValidCookiesFromFile(string filePath) + public async Task BlockAvatarGlobal(string avatarId) { - if (!System.IO.File.Exists(filePath)) - return null; - try { - var json = System.IO.File.ReadAllText(filePath); - var dtoList = JsonConvert.DeserializeObject>(json); - if (dtoList == null || dtoList.Count == 0) - return null; + if (_vrchat == null) + { + logger.Info($"Failed Block avatar {avatarId} globally, not logged in."); + return false; + } - // If any cookie has an Expires value set and is expired, treat the whole set as invalid - DateTime now = DateTime.UtcNow; - foreach (var dto in dtoList) + // Create HTTP client with cookies + using HttpClient httpClient = CreateHttpClientWithCookies(); + + AvatarModerationItem rpt = new() { - if (dto.Expires != DateTime.MinValue && dto.Expires.ToUniversalTime() <= now) - { - return null; - } + TargetAvatarId = avatarId, + AvatarModerationType = "block" + }; + + HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{URI_VRC_BASE_API}/api/1/auth/user/avatarmoderations?targetAvatarId={avatarId}&avatarModerationType=block", rpt); + string responseContent = await response.Content.ReadAsStringAsync(); + logger.Debug($"Response from Block avatar {avatarId} globally: {responseContent}"); + logger.Info($"Submitted Block avatar {avatarId} globally."); + response.EnsureSuccessStatusCode(); + + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + logger.Error($"Error setting avatar moderation status: {ex.Message}"); + } + + return false; + } + + public async Task DeleteAvatarGlobal(string avatarId) + { + try + { + if (_vrchat == null) + { + logger.Info($"Failed Unblock avatar {avatarId} globally, not logged in."); + return false; } - var cookies = dtoList.Select(d => d.ToCookie()).ToList(); - return cookies; + // Create HTTP client with cookies + using HttpClient httpClient = CreateHttpClientWithCookies(); + + HttpResponseMessage response = await httpClient.DeleteAsync($"{URI_VRC_BASE_API}/api/1/auth/user/avatarmoderations?targetAvatarId={avatarId}&avatarModerationType=block"); + string responseContent = await response.Content.ReadAsStringAsync(); + logger.Debug($"Response from Block avatar {avatarId} globally: {responseContent}"); + logger.Info($"Submitted Block avatar {avatarId} globally."); + response.EnsureSuccessStatusCode(); + + return response.IsSuccessStatusCode; } - catch + catch (Exception ex) { - return null; + logger.Error($"Error setting avatar moderation status: {ex.Message}"); } + + return false; } + + #region Avatar Management public Avatar? GetAvatarById(string avatarId) { Avatar? avatar = null; @@ -178,7 +229,7 @@ public async Task Initialize() public List GetAvatarsByUserId(string userId) { - List avatars = new List(); + List avatars = []; try { if (_vrchat != null) @@ -193,11 +244,12 @@ public List GetAvatarsByUserId(string userId) return avatars; } + #endregion - + #region Profile Management public User GetProfile(string userId) { - User profile = new User(); + User profile = new (); try { if (_vrchat != null) @@ -215,7 +267,7 @@ public User GetProfile(string userId) public List GetProfileGroups(string userId) { - List groups = new List(); + List groups = []; try { if (_vrchat != null) @@ -245,19 +297,7 @@ public List GetProfileGroups(string userId) string url = $"{URI_VRC_BASE_API}/api/1/user/{userId}/inventory/{itemId}"; // Create HTTP client with cookies - var handler = new HttpClientHandler - { - CookieContainer = new CookieContainer() - }; - - var cookies = _vrchat.GetCookies(); - foreach (var cookie in cookies) - { - handler.CookieContainer.Add(new Uri(URI_VRC_BASE_API), cookie); - } - - using var httpClient = new HttpClient(handler); - httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + using HttpClient httpClient = CreateHttpClientWithCookies(); var response = await httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); @@ -277,7 +317,9 @@ public List GetProfileGroups(string userId) return item; } + #endregion + #region Image Assets public async Task GetImageReference(string inventoryId, string userId, List imageUrlList) { try @@ -289,23 +331,11 @@ public List GetProfileGroups(string userId) } // Create HTTP client with cookies - var handler = new HttpClientHandler - { - CookieContainer = new CookieContainer() - }; - - var cookies = _vrchat.GetCookies(); - foreach (var cookie in cookies) - { - handler.CookieContainer.Add(new Uri(URI_VRC_BASE_API), cookie); - } - - using HttpClient httpClient = new HttpClient(handler); - httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + using HttpClient httpClient = CreateHttpClientWithCookies(); // Download the image string md5Hash = string.Empty; - List imageList = new List(); + List imageList = []; int imageCount = 0; foreach (string imageUrl in imageUrlList) { @@ -318,7 +348,7 @@ public List GetProfileGroups(string userId) imageList.Add(contentB64); } - ImageReference iref = new ImageReference + ImageReference iref = new () { Base64Data = imageList, Md5Hash = md5Hash, @@ -335,7 +365,9 @@ public List GetProfileGroups(string userId) return null; } } + #endregion + #region Print Management public Print? GetPrintInfo(string fileURL) { Print? printInfo = null; @@ -354,157 +386,115 @@ public List GetProfileGroups(string userId) return printInfo; } + #endregion - public Inventory? GetInventoryInfo(string fileURL) + #region Group Management + internal Group? GetGroupById(string id) { - Inventory? printInfo = null; + Group? group = null; try { if (_vrchat != null) { - printInfo = _vrchat.Inventory.GetInventory(); + group = _vrchat.Groups.GetGroup(id); } } catch (Exception ex) { - logger.Error($"Error fetching avatar: {ex.Message}"); + logger.Error($"Error fetching Group information: {ex.Message}"); } - return printInfo; - } - - public List GetAvatarModerations() - { - List moderations = new List(); - try - { - if (_vrchat != null) - { - moderations = _vrchat.Authentication.GetGlobalAvatarModerations(); - } - } - catch (Exception ex) - { - logger.Error($"Error fetching avatar moderations: {ex.Message}"); - } - return moderations; + return group; } - public async Task BlockAvatarGlobal(string avatarId) + public async Task GetGroupMemberStatus(string groupId, string userId) { try { if (_vrchat == null) { - logger.Info($"Failed Block avatar {avatarId} globally, not logged in."); - return false; + logger.Error("VRChat client not initialized"); + return TGGroupMemberStatus.Unknown; } - // Create HTTP client with cookies - var handler = new HttpClientHandler - { - CookieContainer = new CookieContainer() - }; + GroupLimitedMember membership = _vrchat.Groups.GetGroupMember(groupId, userId); + logger.Info($"Checking group {groupId} member status for user {userId}"); - var cookies = _vrchat.GetCookies(); - foreach (var cookie in cookies) + if( membership != null && membership.MembershipStatus != null) { - handler.CookieContainer.Add(new Uri(URI_VRC_BASE_API), cookie); + if (membership.MembershipStatus == GroupMemberStatus.Banned) + { + return TGGroupMemberStatus.Banned; + } + else if( membership.MembershipStatus == GroupMemberStatus.Member) + { + return TGGroupMemberStatus.Member; + } } - - using HttpClient httpClient = new HttpClient(handler); - httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); - - AvatarModerationItem rpt = new AvatarModerationItem - { - TargetAvatarId = avatarId, - AvatarModerationType = "block" - }; - - HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{URI_VRC_BASE_API}/api/1/auth/user/avatarmoderations?targetAvatarId={avatarId}&avatarModerationType=block", rpt); - string responseContent = await response.Content.ReadAsStringAsync(); - logger.Debug($"Response from Block avatar {avatarId} globally: {responseContent}"); - logger.Info($"Submitted Block avatar {avatarId} globally."); - response.EnsureSuccessStatusCode(); - - return response.IsSuccessStatusCode; + return TGGroupMemberStatus.NotMember; } catch (Exception ex) { - logger.Error($"Error setting avatar moderation status: {ex.Message}"); + logger.Error($"Error checking group member status: {ex.Message}"); + return TGGroupMemberStatus.Unknown; } - - return false; } - - public async Task DeleteAvatarGlobal(string avatarId) + public async Task BanUserFromGroup(string groupId, string userId) { try { if (_vrchat == null) { - logger.Info($"Failed Unblock avatar {avatarId} globally, not logged in."); + logger.Error("VRChat client not initialized"); return false; } + UserIdPayload request = new UserIdPayload { UserId = userId }; // Create HTTP client with cookies - var handler = new HttpClientHandler - { - CookieContainer = new CookieContainer() - }; - - var cookies = _vrchat.GetCookies(); - foreach (var cookie in cookies) - { - handler.CookieContainer.Add(new Uri(URI_VRC_BASE_API), cookie); - } - - using HttpClient httpClient = new HttpClient(handler); - httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + using HttpClient httpClient = CreateHttpClientWithCookies(); - HttpResponseMessage response = await httpClient.DeleteAsync($"{URI_VRC_BASE_API}/api/1/auth/user/avatarmoderations?targetAvatarId={avatarId}&avatarModerationType=block"); + // Submit the moderation report + HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{URI_VRC_BASE_API}/api/1/groups/{groupId}/bans", request); string responseContent = await response.Content.ReadAsStringAsync(); - logger.Debug($"Response from Block avatar {avatarId} globally: {responseContent}"); - logger.Info($"Submitted Block avatar {avatarId} globally."); + logger.Debug($"Response from submitting moderation report for content: {responseContent}"); + logger.Info($"Banning user {userId} from group {groupId}"); response.EnsureSuccessStatusCode(); return response.IsSuccessStatusCode; + } catch (Exception ex) { - logger.Error($"Error setting avatar moderation status: {ex.Message}"); + logger.Error($"Error banning user from group: {ex.Message}"); + return false; } - - return false; } - - private static void SaveCookiesToFile(string filePath, List cookies) + public async Task UnbanUserFromGroup(string groupId, string userId) { - var dtoList = cookies.Select(c => SerializableCookie.FromCookie(c)).ToList(); - var json = JsonConvert.SerializeObject(dtoList, Formatting.Indented); - System.IO.File.WriteAllText(filePath, json); - } - - internal Group? getGroupById(string id) - { - Group? group = null; try { - if (_vrchat != null) + if (_vrchat == null) { - group = _vrchat.Groups.GetGroup(id); + logger.Error("VRChat client not initialized"); + return false; } + + _vrchat.Groups.UnbanGroupMember(groupId, userId); + logger.Info($"Unbanning user {userId} from group {groupId}"); + + return true; } catch (Exception ex) { - logger.Error($"Error fetching Group information: {ex.Message}"); + logger.Error($"Error unbanning user from group: {ex.Message}"); + return false; } - - return group; } + #endregion + #region Moderation Management internal async Task SubmitModerationReportAsync(ModerationReportPayload rpt) { try @@ -516,19 +506,7 @@ internal async Task SubmitModerationReportAsync(ModerationReportPayload rp } // Create HTTP client with cookies - var handler = new HttpClientHandler - { - CookieContainer = new CookieContainer() - }; - - var cookies = _vrchat.GetCookies(); - foreach (var cookie in cookies) - { - handler.CookieContainer.Add(new Uri(URI_VRC_BASE_API), cookie); - } - - using HttpClient httpClient = new HttpClient(handler); - httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + using HttpClient httpClient = CreateHttpClientWithCookies(); // Submit the moderation report HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{URI_VRC_BASE_API}/api/1/moderationReports", rpt); @@ -546,6 +524,77 @@ internal async Task SubmitModerationReportAsync(ModerationReportPayload rp return false; } } + #endregion + + #region Cookie Persistence + private static List? LoadCookies() + { + string filePath = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "cookies.json"); + + if (!System.IO.File.Exists(filePath)) + return null; + + try + { + var json = System.IO.File.ReadAllText(filePath); + var dtoList = JsonConvert.DeserializeObject>(json); + if (dtoList == null || dtoList.Count == 0) + return null; + + // If any cookie has an Expires value set and is expired, treat the whole set as invalid + DateTime now = DateTime.UtcNow; + foreach (var dto in dtoList) + { + logger.Debug($"Loaded cookie: {dto.Name}, Expires: {dto.Expires}"); + + if (dto.Expires != DateTime.MinValue && dto.Expires.ToUniversalTime() <= now) + { + return null; + } + } + + var cookies = dtoList.Select(d => d.ToCookie()).ToList(); + return cookies; + } + catch + { + return null; + } + } + + private static void PersistCookies(List cookies) + { + string filePath = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "cookies.json"); + + var dtoList = cookies.Select(c => SerializableCookie.FromCookie(c)).ToList(); + var json = JsonConvert.SerializeObject(dtoList, Formatting.Indented); + System.IO.File.WriteAllText(filePath, json); + } + + private HttpClient CreateHttpClientWithCookies() + { + if (_vrchat == null) + { + logger.Error("VRChat client not initialized, cannot create HTTP client with cookies."); + throw new InvalidOperationException("VRChat client not initialized"); + } + + var handler = new HttpClientHandler + { + CookieContainer = new CookieContainer() + }; + + var cookies = _vrchat.GetCookies(); + foreach (var cookie in cookies) + { + handler.CookieContainer.Add(new Uri(URI_VRC_BASE_API), cookie); + } + HttpClient httpClient = new(handler); + httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + + return httpClient; + } + #endregion #region Non Public Helper Types private class SerializableCookie @@ -636,7 +685,7 @@ public class VRChatInventoryItem public string FirstAncestorHolderId { get; set; } = string.Empty; [JsonProperty("collections")] - public List Collections { get; set; } = new List(); + public List Collections { get; set; } = []; [JsonProperty("created_at")] public DateTime CreatedAt { get; set; } @@ -651,22 +700,22 @@ public class VRChatInventoryItem public DateTime TemplateUpdatedAt { get; set; } [JsonProperty("defaultAttributes")] - public Dictionary DefaultAttributes { get; set; } = new Dictionary(); + public Dictionary DefaultAttributes { get; set; } = []; [JsonProperty("userAttributes")] - public Dictionary UserAttributes { get; set; } = new Dictionary(); + public Dictionary UserAttributes { get; set; } = []; [JsonProperty("equipSlot")] public string EquipSlot { get; set; } = string.Empty; [JsonProperty("equipSlots")] - public List EquipSlots { get; set; } = new List(); + public List EquipSlots { get; set; } = []; [JsonProperty("expiryDate")] public DateTime? ExpiryDate { get; set; } [JsonProperty("flags")] - public List Flags { get; set; } = new List(); + public List Flags { get; set; } = []; [JsonProperty("isArchived")] public bool IsArchived { get; set; } @@ -681,7 +730,7 @@ public class VRChatInventoryItem public bool Quantifiable { get; set; } [JsonProperty("tags")] - public List Tags { get; set; } = new List(); + public List Tags { get; set; } = []; [JsonProperty("templateId")] public string TemplateId { get; set; } = string.Empty; @@ -726,7 +775,7 @@ public class ModerationReportPayload public string Description { get; set; } = string.Empty; [JsonProperty("details")] - public List Details { get; set; } = new List(); + public List Details { get; set; } = []; } public class ModerationReportDetails @@ -775,6 +824,20 @@ public class PrintFileInfo [JsonProperty("image")] public string ImageUrl { get; set; } = string.Empty; } + + public class UserIdPayload + { + [JsonProperty("userId")] + public string UserId { get; set; } = string.Empty; + } + + public enum TGGroupMemberStatus + { + Unknown, + NotMember, + Member, + Banned + } #endregion } } diff --git a/src/Common/CommonConst.cs b/src/Common/CommonConst.cs index deea7c5..7dd2f58 100644 --- a/src/Common/CommonConst.cs +++ b/src/Common/CommonConst.cs @@ -46,6 +46,42 @@ public static class CommonConst public const string Sound_Alert_Key = "Sound"; public const string Color_Alert_Key = "Color"; + // Highlight class color registry keys + public const string Registry_HighlightClass_Normal_Background = "HIGHLIGHT_NORMAL_BG"; + public const string Registry_HighlightClass_Normal_Foreground = "HIGHLIGHT_NORMAL_FG"; + public const string Registry_HighlightClass_Friend_Background = "HIGHLIGHT_FRIEND_BG"; + public const string Registry_HighlightClass_Friend_Foreground = "HIGHLIGHT_FRIEND_FG"; + public const string Registry_HighlightClass_Class01_Background = "HIGHLIGHT_CLASS01_BG"; + public const string Registry_HighlightClass_Class01_Foreground = "HIGHLIGHT_CLASS01_FG"; + public const string Registry_HighlightClass_Class02_Background = "HIGHLIGHT_CLASS02_BG"; + public const string Registry_HighlightClass_Class02_Foreground = "HIGHLIGHT_CLASS02_FG"; + public const string Registry_HighlightClass_Class03_Background = "HIGHLIGHT_CLASS03_BG"; + public const string Registry_HighlightClass_Class03_Foreground = "HIGHLIGHT_CLASS03_FG"; + public const string Registry_HighlightClass_Class04_Background = "HIGHLIGHT_CLASS04_BG"; + public const string Registry_HighlightClass_Class04_Foreground = "HIGHLIGHT_CLASS04_FG"; + public const string Registry_HighlightClass_Selected_Background = "HIGHLIGHT_SELECTED_BG"; + public const string Registry_HighlightClass_Selected_Foreground = "HIGHLIGHT_SELECTED_FG"; + public const string Registry_HighlightClass_MouseOver_Background = "HIGHLIGHT_MOUSEOVER_BG"; + public const string Registry_HighlightClass_MouseOver_Foreground = "HIGHLIGHT_MOUSEOVER_FG"; + + // Default highlight class colors + public const string Default_HighlightClass_Normal_Background = "#FF1E1E1E"; + public const string Default_HighlightClass_Normal_Foreground = "#FFE6E6E6"; + public const string Default_HighlightClass_Friend_Background = "#FF1E1E1E"; + public const string Default_HighlightClass_Friend_Foreground = "LightGreen"; + public const string Default_HighlightClass_Class01_Background = "Yellow"; + public const string Default_HighlightClass_Class01_Foreground = "Black"; + public const string Default_HighlightClass_Class02_Background = "Red"; + public const string Default_HighlightClass_Class02_Foreground = "Yellow"; + public const string Default_HighlightClass_Class03_Background = "Purple"; + public const string Default_HighlightClass_Class03_Foreground = "Yellow"; + public const string Default_HighlightClass_Class04_Background = "Black"; + public const string Default_HighlightClass_Class04_Foreground = "Yellow"; + public const string Default_HighlightClass_Selected_Background = "#FF1d1db3"; + public const string Default_HighlightClass_Selected_Foreground = "#FFFFFF00"; + public const string Default_HighlightClass_MouseOver_Background = "#FF1d1db3"; + public const string Default_HighlightClass_MouseOver_Foreground = "#FFFFFF00"; + public const string Registry_Discovered_Avatar_Caching = "DISCOVERED_AVATAR_CACHING"; public const string Registry_Moderated_Avatar_Caching = "MODERATED_AVATAR_CACHING"; public const string Registry_Discovered_Group_Caching = "DISCOVERED_GROUP_CACHING"; diff --git a/src/Common/ConfigStore.cs b/src/Common/ConfigStore.cs index 821ab87..9760f9b 100644 --- a/src/Common/ConfigStore.cs +++ b/src/Common/ConfigStore.cs @@ -171,5 +171,10 @@ public static void RemoveStoredKeyString(string keyPath, string keyName) } } + public static void RemoveStoredKeyString(string keyName) + { + RemoveStoredKeyString(CommonConst.ConfigRegistryPath, keyName); + } + } } diff --git a/src/Common/WindowLayoutManager.cs b/src/Common/WindowLayoutManager.cs new file mode 100644 index 0000000..6f188e9 --- /dev/null +++ b/src/Common/WindowLayoutManager.cs @@ -0,0 +1,335 @@ +using Microsoft.Win32; +using NLog; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Forms; + +namespace Tailgrab.Common +{ + public class WindowLayoutManager + { + private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private const string LayoutRegistryPath = "Software\\DeviousFox\\Tailgrab\\Layout"; + + // Default values from XAML + public const double DefaultWindowWidth = 1080; + public const double DefaultWindowHeight = 750; + + // Active Players Tab Column Widths + public const double DefaultActiveDisplayNameWidth = 180; + public const double DefaultActiveAgeWidth = 60; + public const double DefaultActiveAvatarNameWidth = 180; + public const double DefaultActiveInstanceStartWidth = 160; + public const double DefaultActiveAlertMessagesWidth = 330; + public const double DefaultActiveCopyWidth = 60; + public const double DefaultActiveReportWidth = 60; + + // Past Players Tab Column Widths + public const double DefaultPastDisplayNameWidth = 180; + public const double DefaultPastAgeWidth = 60; + public const double DefaultPastAvatarNameWidth = 180; + public const double DefaultPastInstanceEndWidth = 160; + public const double DefaultPastAlertMessagesWidth = 330; + public const double DefaultPastCopyWidth = 60; + public const double DefaultPastReportWidth = 60; + + // Known Avatars Column Widths + public const double DefaultAvatarAlertWidth = 80; + public const double DefaultAvatarNameWidth = 150; + public const double DefaultAvatarIdWidth = 250; + public const double DefaultAvatarUserNameWidth = 150; + public const double DefaultAvatarUpdatedWidth = 150; + public const double DefaultAvatarBrowserWidth = 80; + + // Known Groups Column Widths + public const double DefaultGroupAlertWidth = 120; + public const double DefaultGroupNameWidth = 200; + public const double DefaultGroupIdWidth = 300; + public const double DefaultGroupUpdatedWidth = 200; + public const double DefaultGroupBrowserWidth = 80; + + // Known Users Column Widths + public const double DefaultUserDisplayNameWidth = 200; + public const double DefaultUserIdWidth = 300; + public const double DefaultUserElapsedWidth = 120; + public const double DefaultUserUpdatedWidth = 200; + public const double DefaultUserBrowserWidth = 80; + + // Open Logs Column Widths + public const double DefaultLogFileNameWidth = 400; + public const double DefaultLogOpenedWidth = 180; + public const double DefaultLogLastLineWidth = 180; + public const double DefaultLogLinesProcessedWidth = 120; + public const double DefaultLogActionWidth = 80; + + // GridSplitter positions (stored as GridLength values) + public const double DefaultActiveRowSplitterHeight = 100; + public const double DefaultPastRowSplitterHeight = 100; + + // GridSplitter positions (stored as GridLength values) + public const double DefaultActiveColSplitterWidth = -1; + public const double DefaultPastColSplitterWidth = -1; + + #region Save Methods + + public static void SaveWindowSize(Window window) + { + try + { + if (window.WindowState == WindowState.Normal) + { + using (var key = Registry.CurrentUser.CreateSubKey(LayoutRegistryPath)) + { + key.SetValue("WindowWidth", window.Width); + key.SetValue("WindowHeight", window.Height); + } + logger.Debug($"Saved window size: {window.Width}x{window.Height}"); + } + } + catch (Exception ex) + { + logger.Error(ex, "Failed to save window size to registry."); + } + } + + public static void SaveWindowPosition(Window window) + { + try + { + if (window.WindowState == WindowState.Normal) + { + using (var key = Registry.CurrentUser.CreateSubKey(LayoutRegistryPath)) + { + key.SetValue("WindowLeft", window.Left); + key.SetValue("WindowTop", window.Top); + } + logger.Debug($"Saved window position: Left={window.Left}, Top={window.Top}"); + } + } + catch (Exception ex) + { + logger.Error(ex, "Failed to save window position to registry."); + } + } + + public static void SaveColumnWidth(string columnName, double width) + { + try + { + using (var key = Registry.CurrentUser.CreateSubKey(LayoutRegistryPath)) + { + key.SetValue($"Column_{columnName}", width); + } + logger.Debug($"Saved column width for {columnName}: {width}"); + } + catch (Exception ex) + { + logger.Error(ex, $"Failed to save column width for {columnName}."); + } + } + + public static void SaveSplitterPosition(string splitterName, double height) + { + try + { + using (var key = Registry.CurrentUser.CreateSubKey(LayoutRegistryPath)) + { + key.SetValue($"Splitter_{splitterName}", height); + } + logger.Debug($"Saved splitter height for {splitterName}: {height}"); + } + catch (Exception ex) + { + logger.Error(ex, $"Failed to save splitter height for {splitterName}."); + } + } + #endregion + + #region Load Methods + public static void LoadWindowSize(Window window) + { + try + { + using (var key = Registry.CurrentUser.OpenSubKey(LayoutRegistryPath)) + { + if (key != null) + { + var width = key.GetValue("WindowWidth"); + var height = key.GetValue("WindowHeight"); + + if (width != null && height != null) + { + window.Width = Convert.ToDouble(width); + window.Height = Convert.ToDouble(height); + logger.Debug($"Loaded window size: {window.Width}x{window.Height}"); + } + } + } + } + catch (Exception ex) + { + logger.Error(ex, "Failed to load window size from registry."); + } + } + + public static void LoadWindowPosition(Window window) + { + try + { + using (var key = Registry.CurrentUser.OpenSubKey(LayoutRegistryPath)) + { + if (key != null) + { + var left = key.GetValue("WindowLeft"); + var top = key.GetValue("WindowTop"); + + if (left != null && top != null) + { + double leftPos = Convert.ToDouble(left); + double topPos = Convert.ToDouble(top); + + // Validate that the position is within visible screen bounds + if (IsPositionValid(leftPos, topPos, window.Width, window.Height)) + { + window.Left = leftPos; + window.Top = topPos; + logger.Debug($"Loaded window position: Left={window.Left}, Top={window.Top}"); + } + else + { + logger.Warn("Saved window position is outside visible screen bounds. Using default position."); + } + } + } + } + } + catch (Exception ex) + { + logger.Error(ex, "Failed to load window position from registry."); + } + } + + public static double LoadColumnWidth(string columnName, double defaultWidth) + { + try + { + using (var key = Registry.CurrentUser.OpenSubKey(LayoutRegistryPath)) + { + if (key != null) + { + var value = key.GetValue($"Column_{columnName}"); + if (value != null) + { + double width = Convert.ToDouble(value); + logger.Debug($"Loaded column width for {columnName}: {width}"); + return width; + } + } + } + } + catch (Exception ex) + { + logger.Error(ex, $"Failed to load column width for {columnName}."); + } + return defaultWidth; + } + + public static double LoadSplitterHeight(string splitterName, double defaultHeight) + { + try + { + using (var key = Registry.CurrentUser.OpenSubKey(LayoutRegistryPath)) + { + if (key != null) + { + var value = key.GetValue($"Splitter_{splitterName}"); + if (value != null) + { + double height = Convert.ToDouble(value); + logger.Debug($"Loaded splitter height for {splitterName}: {height}"); + return height; + } + } + } + } + catch (Exception ex) + { + logger.Error(ex, $"Failed to load splitter height for {splitterName}."); + } + return defaultHeight; + } + + #endregion + + #region Reset Methods + + public static void ResetLayoutSettings() + { + try + { + using (var key = Registry.CurrentUser.OpenSubKey(LayoutRegistryPath, writable: true)) + { + if (key != null) + { + // Delete all layout values + foreach (var valueName in key.GetValueNames()) + { + key.DeleteValue(valueName); + } + logger.Info("Reset all layout settings to defaults."); + } + } + } + catch (Exception ex) + { + logger.Error(ex, "Failed to reset layout settings."); + throw; + } + } + + #endregion + + #region Helper Methods + + private static bool IsPositionValid(double left, double top, double width, double height) + { + // Check if at least part of the window is visible on any screen + var windowRect = new Rect(left, top, width, height); + + foreach (var screen in System.Windows.Forms.Screen.AllScreens) + { + var screenRect = new Rect( + screen.WorkingArea.Left, + screen.WorkingArea.Top, + screen.WorkingArea.Width, + screen.WorkingArea.Height); + + if (windowRect.IntersectsWith(screenRect)) + { + return true; + } + } + + return false; + } + + public static void ApplyLayoutToGridView(GridView gridView, Dictionary columnWidths) + { + for (int i = 0; i < gridView.Columns.Count; i++) + { + var column = gridView.Columns[i]; + var header = column.Header as GridViewColumnHeader; + if (header != null) + { + string columnName = header.Tag as string ?? header.Content?.ToString() ?? $"Column{i}"; + if (columnWidths.TryGetValue(columnName, out double width)) + { + column.Width = width; + } + } + } + } + + #endregion + } +} diff --git a/src/LineHandlers/OnPlayerNetworkHandler.cs b/src/LineHandlers/OnPlayerNetworkHandler.cs index 5a9d42d..0b30e1f 100644 --- a/src/LineHandlers/OnPlayerNetworkHandler.cs +++ b/src/LineHandlers/OnPlayerNetworkHandler.cs @@ -1,6 +1,7 @@ namespace Tailgrab.LineHandler; using System.Text.RegularExpressions; +using Tailgrab.PlayerManagement; public class OnPlayerNetworkHandler : AbstractLineHandler { @@ -31,7 +32,7 @@ public override bool HandleLine(string line) } ExecuteActions(); - _serviceRegistry.GetPlayerManager().AssignPlayerNetworkId(userName, networkId); + PlayerManager.AssignPlayerNetworkId(userName, networkId); return true; } diff --git a/src/LineHandlers/PenNetworkIdHandler.cs b/src/LineHandlers/PenNetworkIdHandler.cs index 827a0f8..631d6b9 100644 --- a/src/LineHandlers/PenNetworkIdHandler.cs +++ b/src/LineHandlers/PenNetworkIdHandler.cs @@ -59,17 +59,17 @@ public override bool HandleLine(string line) { string fromPlayerName = "Unknown"; string toPlayerName = "Unknown"; - if (_serviceRegistry.GetPlayerManager().GetPlayerByNetworkId(fromUserId) is Player fromPlayer) + if (PlayerManager.GetPlayerByNetworkId(fromUserId) is Player fromPlayer) { fromPlayerName = fromPlayer.DisplayName; - _serviceRegistry.GetPlayerManager().AddPenEventByDisplayName(fromPlayerName, $"-Pen '{penColor}'."); - _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(fromPlayerName, PlayerEvent.EventType.PenActivity, $"Lost ownership of pen '{penColor}'."); + PlayerManager.AddPenEventByDisplayName(fromPlayerName, $"-Pen '{penColor}'."); + PlayerManager.AddPlayerEventByDisplayName(fromPlayerName, PlayerEvent.EventType.PenActivity, $"Lost ownership of pen '{penColor}'."); } - if (_serviceRegistry.GetPlayerManager().GetPlayerByNetworkId(toUserId) is Player toPlayer) + if (PlayerManager.GetPlayerByNetworkId(toUserId) is Player toPlayer) { toPlayerName = toPlayer.DisplayName; - _serviceRegistry.GetPlayerManager().AddPenEventByDisplayName(toPlayerName, $"+Pen '{penColor}'."); - _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(toPlayerName, PlayerEvent.EventType.PenActivity, $"Took ownership of pen '{penColor}'."); + PlayerManager.AddPenEventByDisplayName(toPlayerName, $"+Pen '{penColor}'."); + PlayerManager.AddPlayerEventByDisplayName(toPlayerName, PlayerEvent.EventType.PenActivity, $"Took ownership of pen '{penColor}'."); } if (LogOutput) { diff --git a/src/LineHandlers/QuitHandler.cs b/src/LineHandlers/QuitHandler.cs index 7be5eca..071cdf3 100644 --- a/src/LineHandlers/QuitHandler.cs +++ b/src/LineHandlers/QuitHandler.cs @@ -2,6 +2,7 @@ namespace Tailgrab.LineHandler; using System.Text.RegularExpressions; using Tailgrab.Common; +using Tailgrab.PlayerManagement; public class QuitHandler : AbstractLineHandler { @@ -35,7 +36,7 @@ public override bool HandleLine(string line) logger.Info($"{COLOR_PREFIX}Application Stop : {time} {COLOR_RESET.GetAnsiEscape()}"); } - _serviceRegistry.GetPlayerManager().ClearAllPlayers(this); + PlayerManager.ClearAllPlayers(this); ExecuteActions(); return true; diff --git a/src/LineHandlers/StickerHandler.cs b/src/LineHandlers/StickerHandler.cs index be9937b..bc61e68 100644 --- a/src/LineHandlers/StickerHandler.cs +++ b/src/LineHandlers/StickerHandler.cs @@ -2,6 +2,7 @@ namespace Tailgrab.LineHandler; using System.Text.RegularExpressions; using Tailgrab.Common; +using Tailgrab.PlayerManagement; public class StickerHandler : AbstractLineHandler { @@ -24,7 +25,6 @@ public override bool HandleLine(string line) Match m = regex.Match(line); if (m.Success) { - string timestamp = m.Groups[VRC_DATETIME].Value; string fileURL = m.Groups[VRC_FILEURL].Value; string userName = m.Groups[VRC_DISPLAYNAME].Value; string userId = m.Groups[VRC_USERID].Value; @@ -32,7 +32,7 @@ public override bool HandleLine(string line) { logger.Info($"{COLOR_PREFIX}{userName} ({userId}) - {fileURL}{COLOR_RESET.GetAnsiEscape()}"); } - _serviceRegistry.GetPlayerManager().AddStickerEvent(userName, userId, fileURL); + PlayerManager.AddStickerEvent(userName, fileURL); ExecuteActions(); return true; diff --git a/src/LineHandlers/VTKHandler.cs b/src/LineHandlers/VTKHandler.cs index 6f84b48..7fd806e 100644 --- a/src/LineHandlers/VTKHandler.cs +++ b/src/LineHandlers/VTKHandler.cs @@ -30,7 +30,7 @@ public override bool HandleLine(string line) logger.Info($"{COLOR_PREFIX}VTK : {userName}{COLOR_RESET.GetAnsiEscape()}"); } - Player? player = _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, "Vote kick initiated against player."); + Player? player = PlayerManager.AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, "Vote kick initiated against player."); if (player != null) { player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Nuisance, $"VTK"); diff --git a/src/LineHandlers/WarnKickHandler.cs b/src/LineHandlers/WarnKickHandler.cs index e3280bd..9ecadc4 100644 --- a/src/LineHandlers/WarnKickHandler.cs +++ b/src/LineHandlers/WarnKickHandler.cs @@ -32,7 +32,7 @@ public override bool HandleLine(string line) logger.Info($"{COLOR_PREFIX}User Moderation : {userName} to {action}{COLOR_RESET.GetAnsiEscape()}"); } - Player? player = _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, $"User has been {action}."); + Player? player = PlayerManager.AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, $"User has been {action}."); if (player != null) { player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Nuisance, action); diff --git a/src/LineHandlers/WorldChangeHandler.cs b/src/LineHandlers/WorldChangeHandler.cs index ba97812..8b22405 100644 --- a/src/LineHandlers/WorldChangeHandler.cs +++ b/src/LineHandlers/WorldChangeHandler.cs @@ -2,6 +2,7 @@ namespace Tailgrab.LineHandler; using System.Text.RegularExpressions; using Tailgrab.Common; +using Tailgrab.PlayerManagement; public class WorldChangeHandler : AbstractLineHandler { @@ -31,8 +32,8 @@ public override bool HandleLine(string line) logger.Info($"{COLOR_PREFIX}World Join : {worldId} as instance {instanceId}{COLOR_RESET.GetAnsiEscape()}"); } - _serviceRegistry.GetPlayerManager().UpdateCurrentSession(worldId, instanceId); - _serviceRegistry.GetPlayerManager().ClearAllPlayers(this); + PlayerManager.UpdateCurrentSession(worldId, instanceId); + PlayerManager.ClearAllPlayers(this); ExecuteActions(); return true; diff --git a/src/Models/GroupInfo.cs b/src/Models/GroupInfo.cs index 42d191c..bb8f61d 100644 --- a/src/Models/GroupInfo.cs +++ b/src/Models/GroupInfo.cs @@ -1,11 +1,9 @@ // This file has been auto generated by EF Core Power Tools. #nullable disable -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using Tailgrab.Common; -using VRChat.API.Model; namespace Tailgrab.Models; diff --git a/src/Models/GroupManagement.cs b/src/Models/GroupManagement.cs new file mode 100644 index 0000000..c43de2a --- /dev/null +++ b/src/Models/GroupManagement.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using Tailgrab.Common; + +namespace tailgrab.src.Models +{ + public partial class GroupManagement + { + public string GroupId { get; set; } + + public string GroupName { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public GroupManagement() + { + UpdatedAt = DateTime.UtcNow; + } + + public override string ToString() + { + return $"GroupId: {GroupId}, GroupName: {GroupName}, CreatedAt: {CreatedAt}, UpdatedAt: {UpdatedAt}"; + } + } +} diff --git a/src/Models/TailgrabDBContext.cs b/src/Models/TailgrabDBContext.cs index 5a3f56b..2929b7b 100644 --- a/src/Models/TailgrabDBContext.cs +++ b/src/Models/TailgrabDBContext.cs @@ -11,6 +11,7 @@ using System.IO; using System.Text.Json; using System.Windows.Media.Animation; +using tailgrab.src.Models; using Tailgrab.Common; namespace Tailgrab.Models; @@ -58,6 +59,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) public virtual DbSet GroupInfos { get; set; } + public virtual DbSet GroupManagements { get; set; } + public virtual DbSet ProfileEvaluations { get; set; } public virtual DbSet ImageEvaluations { get; set; } @@ -90,6 +93,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(g => g.AlertType); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.GroupId); + + entity.ToTable("GroupManagement"); + + entity.Property(e => e.CreatedAt).HasColumnName("createDate"); + entity.Property(e => e.UpdatedAt).HasColumnName("updateDate"); + + entity.HasIndex(g => g.GroupName); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.Md5checksum); @@ -126,18 +141,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public void UpgradeDatabase() { - // CREATE INDEX ix_avtr_aname ON AvatarInfo(AvatarName); - // CREATE INDEX ix_avtr_uname ON AvatarInfo(UserName); - // CREATE INDEX ix_avtr_alert ON AvatarInfo(alertType); - // CREATE INDEX ix_grp_gname ON GroupInfo(groupName); - // CREATE INDEX ix_grp_alert ON GroupInfo(alertType); - ExecuteSqlTransaction( "CREATE INDEX IF NOT EXISTS ix_avtr_aname ON AvatarInfo(AvatarName)", "CREATE INDEX IF NOT EXISTS ix_avtr_uname ON AvatarInfo(UserName)", "CREATE INDEX IF NOT EXISTS ix_avtr_alert ON AvatarInfo(alertType)", "CREATE INDEX IF NOT EXISTS ix_grp_gname ON GroupInfo(groupName)", - "CREATE INDEX IF NOT EXISTS ix_grp_alert ON GroupInfo(alertType)" + "CREATE INDEX IF NOT EXISTS ix_grp_alert ON GroupInfo(alertType)", + "CREATE TABLE IF NOT EXISTS GroupManagement ( GroupId TEXT NOT NULL CONSTRAINT PK_GroupManagement PRIMARY KEY, GroupName TEXT NULL, createDate TEXT NOT NULL, updateDate TEXT NULL )", + "CREATE INDEX IF NOT EXISTS ix_grpm_gname ON GroupManagement(groupName)" ); } @@ -153,6 +164,7 @@ private void ExecuteSqlTransaction(params string[] sqlStatements) { try { + logger.Warn(sql); Database.ExecuteSqlRaw(sql); } catch @@ -162,6 +174,7 @@ private void ExecuteSqlTransaction(params string[] sqlStatements) throw; } } + transaction.Commit(); } private async Task ExecuteSqlAsync(string sql) diff --git a/src/PlayerManagement/AlertColorOption.cs b/src/PlayerManagement/AlertColorOption.cs new file mode 100644 index 0000000..a727f8a --- /dev/null +++ b/src/PlayerManagement/AlertColorOption.cs @@ -0,0 +1,22 @@ +using System.Windows.Media; + +namespace Tailgrab.PlayerManagement +{ + public class AlertColorOption + { + public string Name { get; set; } + public string Value { get; set; } + public System.Windows.Media.Brush BackgroundBrush { get; set; } + public System.Windows.Media.Brush ForegroundBrush { get; set; } + + public AlertColorOption(string name, string value, System.Windows.Media.Brush backgroundBrush, System.Windows.Media.Brush foregroundBrush) + { + Name = name; + Value = value; + BackgroundBrush = backgroundBrush; + ForegroundBrush = foregroundBrush; + } + + public override string ToString() => Name; + } +} diff --git a/src/PlayerManagement/ColorOption.cs b/src/PlayerManagement/ColorOption.cs new file mode 100644 index 0000000..cea1343 --- /dev/null +++ b/src/PlayerManagement/ColorOption.cs @@ -0,0 +1,20 @@ +using System.Windows.Media; + +namespace Tailgrab.PlayerManagement +{ + public class ColorOption + { + public string Name { get; set; } + public string Value { get; set; } + public System.Windows.Media.Brush Brush { get; set; } + + public ColorOption(string name, string value) + { + Name = name; + Value = value; + Brush = (System.Windows.Media.Brush)new BrushConverter().ConvertFromString(value)!; + } + + public override string ToString() => Name; + } +} diff --git a/src/PlayerManagement/GroupBanItem.cs b/src/PlayerManagement/GroupBanItem.cs new file mode 100644 index 0000000..1c8af7c --- /dev/null +++ b/src/PlayerManagement/GroupBanItem.cs @@ -0,0 +1,112 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Tailgrab.PlayerManagement +{ + public class GroupBanItem : INotifyPropertyChanged + { + private string _groupId = string.Empty; + private string _groupName = string.Empty; + private string _status = "Not Checked"; + private bool _canBan = false; + private bool _canUnban = false; + private System.Windows.Media.Brush _statusColor = System.Windows.Media.Brushes.Gray; + + public string GroupId + { + get => _groupId; + set + { + if (_groupId != value) + { + _groupId = value; + OnPropertyChanged(); + } + } + } + + public string GroupName + { + get => _groupName; + set + { + if (_groupName != value) + { + _groupName = value; + OnPropertyChanged(); + } + } + } + + public string Status + { + get => _status; + set + { + if (_status != value) + { + _status = value; + OnPropertyChanged(); + UpdateStatusColor(); + } + } + } + + public bool CanBan + { + get => _canBan; + set + { + if (_canBan != value) + { + _canBan = value; + OnPropertyChanged(); + } + } + } + + public bool CanUnban + { + get => _canUnban; + set + { + if (_canUnban != value) + { + _canUnban = value; + OnPropertyChanged(); + } + } + } + + public System.Windows.Media.Brush StatusColor + { + get => _statusColor; + private set + { + if (_statusColor != value) + { + _statusColor = value; + OnPropertyChanged(); + } + } + } + + private void UpdateStatusColor() + { + StatusColor = Status switch + { + "Member" => System.Windows.Media.Brushes.LightGreen, + "Banned" => System.Windows.Media.Brushes.Red, + "Not Member" => System.Windows.Media.Brushes.Yellow, + _ => System.Windows.Media.Brushes.Gray + }; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/PlayerManagement/PlayerManagement.cs b/src/PlayerManagement/PlayerManagement.cs index 563fc53..3be81ba 100644 --- a/src/PlayerManagement/PlayerManagement.cs +++ b/src/PlayerManagement/PlayerManagement.cs @@ -1,7 +1,9 @@ +using ConcurrentPriorityQueue.Core; +using Microsoft.EntityFrameworkCore; using NLog; using System.ComponentModel; using System.Text; -using Tailgrab.AvatarManagement; +using Tailgrab.Clients.Ollama; using Tailgrab.Clients.VRChat; using Tailgrab.Common; using Tailgrab.LineHandler; @@ -10,7 +12,7 @@ namespace Tailgrab.PlayerManagement { - public class PlayerEvent + public class PlayerEvent(PlayerEvent.EventType type, string eventDescription) { public enum EventType { @@ -28,88 +30,48 @@ public enum EventType } public DateTime EventTime { get; set; } = DateTime.Now; - public EventType Type { get; set; } - public string EventDescription { get; set; } - - public PlayerEvent(EventType type, string eventDescription) - { - Type = type; - EventDescription = eventDescription; - } + public EventType Type { get; set; } = type; + public string EventDescription { get; set; } = eventDescription; } - public class PlayerInventory + public class PlayerInventory(string inventoryId, string itemName, string itemUrl, string inventoryType, string aIEvaluation) { - public string InventoryId { get; set; } - public string ItemName { get; set; } - public string ItemUrl { get; set; } - public string InventoryType { get; set; } - public string AIEvaluation { get; set; } - public DateTime SpawnedAt { get; set; } - public PlayerInventory(string inventoryId, string itemName, string itemUrl, string inventoryType, string aIEvaluation) - { - InventoryId = inventoryId; - ItemName = itemName; - SpawnedAt = DateTime.Now; - ItemUrl = itemUrl; - InventoryType = inventoryType; - AIEvaluation = aIEvaluation; - } + public string InventoryId { get; set; } = inventoryId; + public string ItemName { get; set; } = itemName; + public string ItemUrl { get; set; } = itemUrl; + public string InventoryType { get; set; } = inventoryType; + public string AIEvaluation { get; set; } = aIEvaluation; + public DateTime SpawnedAt { get; set; } = DateTime.Now; } - public class PlayerPrint + public class PlayerPrint(VRChat.API.Model.Print p, string aiEvaluation, string aiClassification) { - public string PrintId { get; set; } - public string OwnerId { get; set; } - public DateTime Timestamp { get; set; } - public DateTime CreatedAt { get; set; } - public string PrintUrl { get; set; } - public string AIEvaluation { get; set; } - public string AIClass { get; set; } - public string AuthorName { get; set; } - - public PlayerPrint(VRChat.API.Model.Print p, string aiEvaluation, string aiClassification) - { - PrintId = p.Id; - OwnerId = p.OwnerId; - Timestamp = DateTime.Now; - CreatedAt = p.CreatedAt; - PrintUrl = p.Files.Image; - AuthorName = p.AuthorName; - AIEvaluation = aiEvaluation; - AIClass = aiClassification; - } + public string PrintId { get; set; } = p.Id; + public string OwnerId { get; set; } = p.OwnerId; + public DateTime Timestamp { get; set; } = DateTime.Now; + public DateTime CreatedAt { get; set; } = p.CreatedAt; + public string PrintUrl { get; set; } = p.Files.Image; + public string AIEvaluation { get; set; } = aiEvaluation; + public string AIClass { get; set; } = aiClassification; + public string AuthorName { get; set; } = p.AuthorName; } - public class AlertMessage + public class AlertMessage(AlertClassEnum alertClass, AlertTypeEnum alertType, string color, string message) { - public AlertClassEnum AlertClass { get; set; } - public AlertTypeEnum AlertType { get; set; } - public string Color { get; set; } - public string Message { get; set; } - public DateTime Timestamp { get; set; } - public AlertMessage(AlertClassEnum alertClass, AlertTypeEnum alertType, string color, string message) - { - AlertClass = alertClass; - AlertType = alertType; - Color = color; - Message = message; - Timestamp = DateTime.Now; - } + public AlertClassEnum AlertClass { get; set; } = alertClass; + public AlertTypeEnum AlertType { get; set; } = alertType; + public string Color { get; set; } = color; + public string Message { get; set; } = message; + public DateTime Timestamp { get; set; } = DateTime.Now; } - public class PlayerAvatar + public class PlayerAvatar(string avatarName, string createdBy) { - public string AvatarName { get; set; } - public string? CreatedBy { get; set; } - public PlayerAvatar(string avatarName, string createdBy) - { - AvatarName = avatarName; - CreatedBy = createdBy; - } + public string AvatarName { get; set; } = avatarName; + public string? CreatedBy { get; set; } = createdBy; } - public class Player : INotifyPropertyChanged + public class Player(string userId, string displayName, SessionInfo session) : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; @@ -118,23 +80,23 @@ protected virtual void OnPropertyChanged(string propertyName) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public string UserId { get; set; } - public string DisplayName { get; set; } - public string AvatarName { get; set; } - public string PenActivity { get; set; } + public string UserId { get; set; } = userId; + public string DisplayName { get; set; } = displayName; + public string AvatarName { get; set; } = ""; + public string PenActivity { get; set; } = ""; public int NetworkId { get; set; } - public DateTime InstanceStartTime { get; set; } + public DateTime InstanceStartTime { get; set; } = DateTime.Now; public DateTime? InstanceEndTime { get; set; } - public List Events { get; set; } = new List(); - public List Inventory { get; set; } = new List(); - public SessionInfo Session { get; set; } + public List Events { get; set; } = []; + public List Inventory { get; set; } = []; + public SessionInfo Session { get; set; } = session; public string? LastStickerUrl { get; set; } = string.Empty; - public Dictionary PrintData = new Dictionary(); + public Dictionary PrintData = []; public string? UserBio { get; set; } public string? AIEval { get; set; } - public List _AlertMessage = new List(); + public List _AlertMessage = []; public string AlertMessage { @@ -231,7 +193,7 @@ public bool IsWatched { get { - if (_AlertMessage.Count() > 0) + if (_AlertMessage.Count > 0) { return true; } @@ -256,21 +218,10 @@ public bool IsFriend { _isFriend = value; } } - public Player(string userId, string displayName, SessionInfo session) - { - UserId = userId; - DisplayName = displayName; - AvatarName = ""; - PenActivity = ""; - InstanceStartTime = DateTime.Now; - Session = session; - } - - public void AddAlertMessage(AlertClassEnum alertClass, AlertTypeEnum alertType, string message) { string alertColor = PlayerManager.GetAlertColor(alertClass, alertType); - AlertMessage newAlert = new AlertMessage(alertClass, alertType, alertColor, message); + AlertMessage newAlert = new (alertClass, alertType, alertColor, message); _AlertMessage.Add(newAlert); foreach (AlertMessage alert in _AlertMessage) @@ -298,7 +249,7 @@ public override string ToString() public string ToString(bool full) { - StringBuilder sb = new System.Text.StringBuilder(); + StringBuilder sb = new (); sb.AppendLine($"DisplayName: {DisplayName}"); sb.AppendLine($"UserId: {UserId}"); sb.AppendLine($"Current Avatar Name: {(string.IsNullOrEmpty(AvatarName) ? string.Empty : AvatarName)}"); @@ -345,20 +296,14 @@ public string ToString(bool full) } } - public class SessionInfo + public class SessionInfo(string worldId, string instanceId) { - public string WorldId { get; set; } - public string InstanceId { get; set; } - public DateTime startDateTime { get; } = DateTime.Now; - public SessionInfo(string worldId, string instanceId) - { - WorldId = worldId; - InstanceId = instanceId; - startDateTime = DateTime.Now; - } + public string WorldId { get; set; } = worldId; + public string InstanceId { get; set; } = instanceId; + public DateTime StartDateTime { get; } = DateTime.Now; } - public class PlayerChangedEventArgs : EventArgs + public class PlayerChangedEventArgs(PlayerChangedEventArgs.ChangeType type, Player player) : EventArgs { public enum ChangeType { @@ -368,41 +313,39 @@ public enum ChangeType Cleared } - public ChangeType Type { get; } - public Player Player { get; } - - public PlayerChangedEventArgs(ChangeType type, Player player) - { - Type = type; - Player = player; - } + public ChangeType Type { get; } = type; + public Player Player { get; } = player; } public class PlayerManager { - private ServiceRegistry serviceRegistry; + private static ServiceRegistry serviceRegistry; + public PlayerManager(ServiceRegistry registry) + { + serviceRegistry = registry; + _ = Task.Run(() => AvatarCheckTask(priorityQueue, serviceRegistry)); + } - private static Dictionary playersByUserId = new Dictionary(); - private static Dictionary userIdByNetworkId = new Dictionary(); - private static Dictionary userIdByDisplayName = new Dictionary(); - private static Dictionary avatarByDisplayName = new Dictionary(); - private static Dictionary PlayerAvatarByName = new Dictionary(); + private static Dictionary playersByUserId = []; + private static Dictionary userIdByNetworkId = []; + private static Dictionary userIdByDisplayName = []; + private static Dictionary avatarByDisplayName = []; + private static Dictionary PlayerAvatarByName = []; + public static SessionInfo CurrentSession = new("", ""); public static readonly AnsiColor COLOR_PREFIX_LEAVE = AnsiColor.Yellow; public static readonly AnsiColor COLOR_PREFIX_JOIN = AnsiColor.Green; public static readonly AnsiColor COLOR_RESET = AnsiColor.Reset; - public static Logger logger = LogManager.GetCurrentClassLogger(); - public static SessionInfo CurrentSession = new SessionInfo("", ""); + protected static readonly Logger logger = LogManager.GetCurrentClassLogger(); // Event for UI and other listeners public static event EventHandler? PlayerChanged; - public PlayerManager(ServiceRegistry registry) - { - serviceRegistry = registry; - } + private static ConcurrentPriorityQueue, int> priorityQueue = new(); + private static Dictionary recentlyProcessedAvatars = []; + - public Player? GetPlayerByDisplayName(string displayName) + public static Player? GetPlayerByDisplayName(string displayName) { if (userIdByDisplayName.TryGetValue(displayName, out string? userId)) { @@ -411,7 +354,7 @@ public PlayerManager(ServiceRegistry registry) return null; } - public Player? GetPlayerByNetworkId(int networkId) + public static Player? GetPlayerByNetworkId(int networkId) { if (userIdByNetworkId.TryGetValue(networkId, out string? userId)) { @@ -420,14 +363,14 @@ public PlayerManager(ServiceRegistry registry) return null; } - public Player? GetPlayerByUserId(string userId) + public static Player? GetPlayerByUserId(string userId) { playersByUserId.TryGetValue(userId, out Player? player); return player; } - public void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, Player player) + public static void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, Player player) { try { @@ -439,7 +382,7 @@ public void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, Player } } - public void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, string displayName) + public static void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, string displayName) { try { @@ -455,15 +398,15 @@ public void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, string } } - public void UpdateCurrentSession(string worldId, string instanceId) + public static void UpdateCurrentSession(string worldId, string instanceId) { CurrentSession = new SessionInfo(worldId, instanceId); } public void PlayerJoined(string userId, string displayName, AbstractLineHandler handler) { - Player? player = null; - if (!playersByUserId.ContainsKey(userId)) + Player? player; + if (!playersByUserId.TryGetValue(userId, out Player? value)) { player = new Player(userId, displayName, CurrentSession); if (handler.LogOutput) @@ -474,7 +417,7 @@ public void PlayerJoined(string userId, string displayName, AbstractLineHandler else { // If existing, treat as update (display name may have changed etc.) - player = playersByUserId[userId]; + player = value; if (player.DisplayName != displayName) { // remove old display-name mapping if present @@ -527,12 +470,14 @@ public void PlayerLeft(string displayName, AbstractLineHandler handler) UserInfo? user = dBContext.UserInfos.Find(player.UserId); if (user == null) { - user = new UserInfo(); - user.DisplayName = player.DisplayName; - user.UserId = player.UserId; - user.CreatedAt = DateTime.Now; - user.UpdatedAt = DateTime.Now; - user.ElapsedMinutes = timeDifference.TotalMinutes; + user = new UserInfo + { + DisplayName = player.DisplayName, + UserId = player.UserId, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + ElapsedMinutes = timeDifference.TotalMinutes + }; dBContext.Add(user); dBContext.SaveChanges(); } @@ -540,7 +485,7 @@ public void PlayerLeft(string displayName, AbstractLineHandler handler) { user.DisplayName = player.DisplayName; user.UpdatedAt = DateTime.Now; - user.ElapsedMinutes = user.ElapsedMinutes + timeDifference.TotalMinutes; + user.ElapsedMinutes += timeDifference.TotalMinutes; dBContext.Update(user); dBContext.SaveChanges(); } @@ -559,7 +504,7 @@ public void PlayerLeft(string displayName, AbstractLineHandler handler) } } - public Player? AssignPlayerNetworkId(string displayName, int networkId) + public static Player? AssignPlayerNetworkId(string displayName, int networkId) { Player? player = GetPlayerByDisplayName(displayName); if (player != null) @@ -571,12 +516,12 @@ public void PlayerLeft(string displayName, AbstractLineHandler handler) return player; } - public IEnumerable GetAllPlayers() + public static IEnumerable GetAllPlayers() { return playersByUserId.Values; } - public void ClearAllPlayers(AbstractLineHandler handler) + public static void ClearAllPlayers(AbstractLineHandler handler) { foreach (var player in playersByUserId.Values) { @@ -598,12 +543,12 @@ public void ClearAllPlayers(AbstractLineHandler handler) OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Cleared, new Player("", "", CurrentSession) { InstanceStartTime = DateTime.MinValue }); } - public int GetPlayerCount() + public static int GetPlayerCount() { return playersByUserId.Count; } - public void LogAllPlayers(AbstractLineHandler handler) + public static void LogAllPlayers(AbstractLineHandler handler) { if (handler.LogOutput) { @@ -614,7 +559,7 @@ public void LogAllPlayers(AbstractLineHandler handler) } } - public Player? AddPlayerEventByDisplayName(string displayName, PlayerEvent.EventType eventType, string eventDescription) + public static Player? AddPlayerEventByDisplayName(string displayName, PlayerEvent.EventType eventType, string eventDescription) { if (userIdByDisplayName.TryGetValue(displayName, out string? userId)) @@ -625,11 +570,11 @@ public void LogAllPlayers(AbstractLineHandler handler) return null; } - public Player? AddPlayerEventByUserId(string userId, PlayerEvent.EventType eventType, string eventDescription) + public static Player? AddPlayerEventByUserId(string userId, PlayerEvent.EventType eventType, string eventDescription) { if (playersByUserId.TryGetValue(userId, out Player? player)) { - PlayerEvent newEvent = new PlayerEvent(eventType, eventDescription); + PlayerEvent newEvent = new(eventType, eventDescription); player.AddEvent(newEvent); return player; } @@ -644,13 +589,13 @@ public void SetAvatarForPlayer(string displayName, string avatarName) Player? player = AddPlayerEventByDisplayName(displayName, PlayerEvent.EventType.AvatarWatch, $"User switched to Avatar : {avatarName}"); ; if (player != null) { - AvatarInfo? watchedAvatar = serviceRegistry.GetAvatarManager().CheckAvatarByName(avatarName); + AvatarInfo? watchedAvatar = PlayerManager.CheckAvatarByName(avatarName); if (watchedAvatar != null) { - logger.Info($"{COLOR_PREFIX_LEAVE.GetAnsiEscape()}Watched Avatar Detected for Player {displayName}: {avatarName} with AlertType {watchedAvatar.AlertType.ToString()}{COLOR_RESET.GetAnsiEscape()}"); + logger.Info($"{COLOR_PREFIX_LEAVE.GetAnsiEscape()}Watched Avatar Detected for Player {displayName}: {avatarName} with AlertType {watchedAvatar.AlertType}{COLOR_RESET.GetAnsiEscape()}"); if (watchedAvatar.AlertType > AlertTypeEnum.None) { - player = AddPlayerEventByDisplayName(displayName, PlayerEvent.EventType.AvatarWatch, $"User has used a watched Avatar : {avatarName} alertType: {watchedAvatar.AlertType.ToString()}"); + player = AddPlayerEventByDisplayName(displayName, PlayerEvent.EventType.AvatarWatch, $"User has used a watched Avatar : {avatarName} alertType: {watchedAvatar.AlertType}"); player?.AddAlertMessage(AlertClassEnum.Avatar, watchedAvatar.AlertType, $"{avatarName}"); } } @@ -662,15 +607,7 @@ public void SetAvatarForPlayer(string displayName, string avatarName) } } - public void UnpackAvatar(string avatarName, string uploadedBy) - { - PlayerAvatar playerAvatar = UpdatePlayerAvatar(avatarName, uploadedBy); - - // - // Check Avatar IDs - } - - public PlayerAvatar UpdatePlayerAvatar(string avatarName, string uploadedBy) + public static PlayerAvatar UpdatePlayerAvatar(string avatarName, string uploadedBy) { if (PlayerAvatarByName.TryGetValue(avatarName, out PlayerAvatar? playerAvatar)) @@ -686,12 +623,12 @@ public PlayerAvatar UpdatePlayerAvatar(string avatarName, string uploadedBy) return playerAvatar; } - private void PrintPlayerInfo(Player player) + private static void PrintPlayerInfo(Player player) { - logger.Info($"{COLOR_PREFIX_LEAVE.GetAnsiEscape()}Player Left: \n{player.ToString()}{COLOR_RESET.GetAnsiEscape()}"); + logger.Info($"{COLOR_PREFIX_LEAVE.GetAnsiEscape()}Player Left: \n{player}{COLOR_RESET.GetAnsiEscape()}"); } - internal void AddPenEventByDisplayName(string displayName, string eventText) + internal static void AddPenEventByDisplayName(string displayName, string eventText) { Player? player = GetPlayerByDisplayName(displayName); if (player != null) @@ -711,8 +648,6 @@ internal async void AddInventorySpawn(string userId, string inventoryId) string itemContent = ""; string inventoryType = "Unknown Type"; string aiClassification = "OK"; - string evaluatedText = "Not Evaluated"; - try { var inventoryItem = await serviceRegistry.GetVRChatAPIClient()?.GetUserInventoryItem(userId, inventoryId)!; @@ -736,10 +671,10 @@ internal async void AddInventorySpawn(string userId, string inventoryId) var ollamaClient = serviceRegistry.GetOllamaAPIClient(); if (ollamaClient != null) { - ImageEvaluation? evaluated = await ollamaClient.ClassifyImageList(userId, inventoryId, new List { itemUrl, itemContent }); + ImageEvaluation? evaluated = await ollamaClient.ClassifyImageList(userId, inventoryId, [itemUrl, itemContent]); if (evaluated != null) { - evaluatedText = System.Text.Encoding.UTF8.GetString(evaluated.Evaluation); + string evaluatedText = System.Text.Encoding.UTF8.GetString(evaluated.Evaluation); aiClassification = EvaluateImageClass(evaluatedText) ?? "OK"; logger.Info($"Ollama classification for inventory item {inventoryId}: {aiClassification}: {evaluatedText}"); if (!aiClassification.Equals("OK") && !evaluated.IsIgnored) @@ -750,7 +685,7 @@ internal async void AddInventorySpawn(string userId, string inventoryId) } } - PlayerInventory inventory = new PlayerInventory(inventoryId, itemName, itemContent, inventoryType, aiClassification); + PlayerInventory inventory = new(inventoryId, itemName, itemContent, inventoryType, aiClassification); player.Inventory.Add(inventory); AddPlayerEventByUserId(userId, PlayerEvent.EventType.Emoji, $"Spawned Item: {itemName} ({inventoryId})"); @@ -783,7 +718,7 @@ internal async void AddInventorySpawn(string userId, string inventoryId) } private static bool CheckLines(string input, string knownString) { - string[] lines = input.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); + string[] lines = input.Split(['\n'], StringSplitOptions.RemoveEmptyEntries); if (lines.Length < 2) { @@ -795,7 +730,7 @@ private static bool CheckLines(string input, string knownString) return firstLineContains; } - internal void AddStickerEvent(string displayName, string userId, string fileURL) + internal static void AddStickerEvent(string displayName, string fileURL) { Player? player = GetPlayerByDisplayName(displayName); if (player != null) @@ -806,11 +741,6 @@ internal void AddStickerEvent(string displayName, string userId, string fileURL) } } - internal void CompactDatabase() - { - serviceRegistry.GetAvatarManager().CompactDatabase(); - } - internal async void AddPrintData(string printId) { if (serviceRegistry.GetVRChatAPIClient() != null) @@ -822,15 +752,14 @@ internal async void AddPrintData(string printId) if (player != null) { logger.Info($"Fetched print info for print {printId} owned by {player.DisplayName} (ID: {printInfo.OwnerId})"); - ImageEvaluation? evaluated = null; string evaluatedText = "Not Evaluated"; string aiClassification = "OK"; var ollamaClient = serviceRegistry.GetOllamaAPIClient(); if (ollamaClient != null) { - List imageUrls = new List(); + List imageUrls = []; imageUrls.Add(printInfo.Files.Image); - evaluated = await ollamaClient.ClassifyImageList(printInfo.OwnerId, printInfo.Id, imageUrls); + ImageEvaluation? evaluated = await ollamaClient.ClassifyImageList(printInfo.OwnerId, printInfo.Id, imageUrls); if (evaluated != null) { evaluatedText = System.Text.Encoding.UTF8.GetString(evaluated.Evaluation); @@ -853,7 +782,8 @@ internal async void AddPrintData(string printId) public Player? UpdatePlayerUserFromVRCProfile(User profile, string profileHash) { - if (profile != null) + logger.Warn($"Updating UserInfo for user {profile.DisplayName} (ID: {profile.Id}) with DateJoined: {profile.DateJoined} and ProfileHash: {profileHash}"); + if (profile != null && profile.Id != null) { TailgrabDBContext dbContext = serviceRegistry.GetDBContext(); Player? player = GetPlayerByUserId(profile.Id); @@ -874,13 +804,15 @@ internal async void AddPrintData(string printId) } else { - user = new UserInfo(); - user.DisplayName = profile.DisplayName; - user.UserId = profile.Id; - user.CreatedAt = DateTime.UtcNow; - user.UpdatedAt = DateTime.UtcNow; - user.DateJoined = profile.DateJoined; - user.LastProfileChecksum = profileHash; + user = new UserInfo + { + DisplayName = profile.DisplayName, + UserId = profile.Id, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + DateJoined = profile.DateJoined, + LastProfileChecksum = profileHash + }; dbContext.Add(user); } dbContext.SaveChanges(); @@ -903,14 +835,14 @@ internal async void AddPrintData(string printId) try { VRChatClient vrcClient = serviceRegistry.GetVRChatAPIClient(); - VRChat.API.Model.Group? group = vrcClient.getGroupById(groupId); + VRChat.API.Model.Group? group = vrcClient.GetGroupById(groupId); if (group != null) { TailgrabDBContext dbContext = serviceRegistry.GetDBContext(); GroupInfo? existing = dbContext.GroupInfos.Find(group.Id); if (existing == null) { - GroupInfo newEntity = new GroupInfo + GroupInfo newEntity = new() { GroupId = group.Id, GroupName = group.Name ?? string.Empty, @@ -963,8 +895,8 @@ public void SyncAvatarModerations() AvatarInfo? existingAvatar = dBContext.AvatarInfos.Find(mod.TargetAvatarId); if (existingAvatar == null || existingAvatar.AlertType < AlertTypeEnum.Nuisance ) { - QueuedModeratedAvatarWatch watchItem = new QueuedModeratedAvatarWatch(2, mod.TargetAvatarId, AlertTypeEnum.Nuisance, lineNumber); - serviceRegistry.GetAvatarManager().EnqueueModeratedAvatarForCheck(watchItem); + QueuedModeratedAvatarWatch watchItem = new(2, mod.TargetAvatarId, AlertTypeEnum.Nuisance, lineNumber); + EnqueueModeratedAvatarForCheck(watchItem); } } } @@ -995,5 +927,447 @@ public static string GetAlertColor(AlertClassEnum alertClass, AlertTypeEnum aler } #endregion + + + #region Avatar Management + public static int GetQueueCount() + { + return priorityQueue.Count; + } + + public void AddAvatar(AvatarInfo avatar) + { + try + { + serviceRegistry.GetDBContext().AvatarInfos.Add(avatar); + serviceRegistry.GetDBContext().SaveChanges(); + } + catch (Exception ex) + { + logger.Error($"Error creating avatar: {ex.Message}"); + } + } + + public static AvatarInfo? GetAvatarById(string avatarId) + { + return serviceRegistry.GetDBContext().AvatarInfos.Find(avatarId); + } + + public void UpdateAvatar(AvatarInfo avatar) + { + try + { + avatar.UpdatedAt = DateTime.UtcNow; + serviceRegistry.GetDBContext().AvatarInfos.Update(avatar); + serviceRegistry.GetDBContext().SaveChanges(); + } + catch (Exception ex) + { + logger.Error($"Error updating avatar: {ex.Message}"); + } + } + + public void DeleteAvatar(string avatarId) + { + var avatar = serviceRegistry.GetDBContext().AvatarInfos.Find(avatarId); + if (avatar != null) + { + serviceRegistry.GetDBContext().AvatarInfos.Remove(avatar); + serviceRegistry.GetDBContext().SaveChanges(); + } + } + + public static void CacheAvatars(List avatarIdInCache) + { + foreach (var avatarId in avatarIdInCache) + { + EnqueueAvatarForCheck(avatarId); + } + } + + private static void EnqueueAvatarForCheck(string avatarId) + { + if (recentlyProcessedAvatars.TryGetValue(avatarId, out DateTime dateTime)) + { + if ((DateTime.UtcNow - dateTime).TotalMinutes < 60) + { + return; + } + } + recentlyProcessedAvatars.Add(avatarId, DateTime.UtcNow); + + var queuedItem = new QueuedAvatarProcess(5, avatarId); + + priorityQueue.Enqueue(queuedItem); + } + + public static void EnqueueWatchAvatarForCheck(QueuedAvatarWatch watch) + { + priorityQueue.Enqueue(watch); + } + + public void EnqueueModeratedAvatarForCheck(QueuedModeratedAvatarWatch watch) + { + priorityQueue.Enqueue(watch); + } + + + public void GetAvatarsFromUser(string userId, string avatarName) + { + + logger.Debug($"Fetching avatars for user {userId} to find avatar named {avatarName}"); + + try + { + // Avatar already exists in the database and was updated within the last 12 hours + System.Threading.Thread.Sleep(500); + List avatarData = serviceRegistry.GetVRChatAPIClient().GetAvatarsByUserId(userId); + foreach (var avatar in avatarData) + { + logger.Debug(avatar.ToString()); + if (avatar.Name.Equals(avatarName, StringComparison.OrdinalIgnoreCase)) + { + AvatarInfo? dbAvatarInfo = GetAvatarById(avatar.Id); + + if (dbAvatarInfo == null) + { + var avatarInfo = new AvatarInfo + { + AvatarId = avatar.Id, + UserId = avatar.AuthorId, + AvatarName = avatar.Name, + ImageUrl = avatar.ImageUrl, + CreatedAt = avatar.CreatedAt, + UpdatedAt = DateTime.UtcNow, + AlertType = AlertTypeEnum.None, + UserName = avatar.AuthorName + }; + + AddAvatar(avatarInfo); + } + else + { + dbAvatarInfo.UserId = avatar.AuthorId; + dbAvatarInfo.UserName = avatar.AuthorName; + dbAvatarInfo.AvatarName = avatar.Name; + dbAvatarInfo.ImageUrl = avatar.ImageUrl; + dbAvatarInfo.CreatedAt = avatar.CreatedAt; + UpdateAvatar(dbAvatarInfo); + } + } + } + } + catch (Exception ex) + { + logger.Error($"Error fetching avatar: {ex.Message}"); + } + } + + public void CompactDatabase() + { + serviceRegistry.GetDBContext().Database.ExecuteSqlRaw("VACUUM;"); + } + + public static AvatarInfo? CheckAvatarByName(string avatarName) + { + var bannedAvatars = serviceRegistry.GetDBContext().AvatarInfos + .Where(b => b.AvatarName != null && b.AvatarName.Equals(avatarName) && b.AlertType > 0) + .OrderByDescending(b => b.AlertType) + .ToList(); + + if (bannedAvatars.Count > 0) + { + // Play alert sound based on the highest alert type found for the avatar + AlertTypeEnum maxAlertType = bannedAvatars[0].AlertType; + SoundManager.PlayAlertSound(CommonConst.Avatar_Alert_Key, maxAlertType); + + return bannedAvatars[0]; + } + + return null; + } + + public static async Task AvatarCheckTask(ConcurrentPriorityQueue, int> priorityQueue, ServiceRegistry serviceRegistry) + { + OllamaClient.logger.Info($"Amplitude Avatar Cache Queue Running"); + TailgrabDBContext dBContext = serviceRegistry.GetDBContext(); + while (true) + { + // Process items from the priority queue + while (true) + { + var result = priorityQueue.Dequeue(); + if (result.IsSuccess) + { + if (result.Value is QueuedAvatarProcess item && item.AvatarId != null) + { + await UpdateAmpAvatarRecord(serviceRegistry, dBContext, item.AvatarId); + } + else if (result.Value is QueuedAvatarWatch item2) + { + await UpdateWatchedAvatarRecord(serviceRegistry, dBContext, item2); + } + else if (result.Value is QueuedModeratedAvatarWatch item3) + { + await UpdateModeratedAvatarRecord(serviceRegistry, dBContext, item3); + } + } + else + { + // No more items to process + break; + } + } + // Wait for a short period before checking the queue again + await Task.Delay(5000); + } + } + + private static async Task UpdateAmpAvatarRecord(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, string avatarId) + { + try + { + AvatarInfo? dbAvatarInfo = dBContext.AvatarInfos.Find(avatarId); + bool updateNeeded = false; + if (dbAvatarInfo == null) + { + updateNeeded = true; + } + else if (dbAvatarInfo.AlertType == AlertTypeEnum.None && + (!dbAvatarInfo.UpdatedAt.HasValue || dbAvatarInfo.UpdatedAt.Value >= DateTime.UtcNow.AddHours(-2))) + { + updateNeeded = true; + } + + if (updateNeeded) + { + // Adds and Updates avatar info in the database, if it doesn't exist or was last updated more than 2 hours ago + Avatar? avatarData = FetchUpdateAvatarData(serviceRegistry, dBContext, avatarId, dbAvatarInfo); + + if (avatarData == null && dbAvatarInfo == null) + { + // Private Avatar + CreateAvatarInfoForPrivate(dBContext, avatarId); + } + + // Wait for a short period before checking the queue again + await Task.Delay(1000); + } + } + catch (Exception ex) + { + logger.Error(ex, $"Error fetching user profile for userId: {avatarId}"); + } + } + + + private static async Task UpdateModeratedAvatarRecord(ServiceRegistry _serviceRegistry, TailgrabDBContext dbContext, QueuedModeratedAvatarWatch watch) + { + try + { + // Fetch the AvatarInfo record + AvatarInfo? avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); + PlayerManager.FetchUpdateAvatarData(_serviceRegistry, dbContext, watch.AvatarId, avatarInfo); + avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); + + if (avatarInfo == null) + { + logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' not found in database/vrc, skipping."); + } + else if (avatarInfo.AlertType == AlertTypeEnum.None) + { + avatarInfo.AlertType = AlertTypeEnum.Nuisance; + avatarInfo.UpdatedAt = DateTime.UtcNow; + dbContext.AvatarInfos.Update(avatarInfo); + dbContext.SaveChanges(); + } + else + { + logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' already has Has an Alert, skipping."); + } + } + catch (Exception ex) + { + logger.Error(ex, $"Line {watch.LineNumber}: Error processing avatar ID '{watch.AvatarId}'"); + } + + // Throttle processing to avoid overwhelming the API + await Task.Delay(1000); + } + + private static async Task UpdateWatchedAvatarRecord(ServiceRegistry _serviceRegistry, TailgrabDBContext dbContext, QueuedAvatarWatch watch) + { + try + { + // Fetch the AvatarInfo record + AvatarInfo? avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); + PlayerManager.FetchUpdateAvatarData(_serviceRegistry, dbContext, watch.AvatarId, avatarInfo); + avatarInfo = await dbContext.AvatarInfos.FindAsync(watch.AvatarId); + + if (avatarInfo == null) + { + logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' not found in database/vrc, skipping."); + } + else if (avatarInfo.AlertType == AlertTypeEnum.None) + { + + avatarInfo.AlertType = watch.AlertType; + avatarInfo.UpdatedAt = DateTime.UtcNow; + dbContext.AvatarInfos.Update(avatarInfo); + dbContext.SaveChanges(); + + if (avatarInfo.AlertType >= AlertTypeEnum.Nuisance) + { + await _serviceRegistry.GetVRChatAPIClient().BlockAvatarGlobal(avatarInfo.AvatarId); + } + else + { + await _serviceRegistry.GetVRChatAPIClient().DeleteAvatarGlobal(avatarInfo.AvatarId); + } + + logger.Debug($"Line {watch.LineNumber}: Set Watch State for Avatar ID '{watch.AvatarId}'"); + + } + else + { + logger.Debug($"Line {watch.LineNumber}: Avatar ID '{watch.AvatarId}' already has Has an Alert, skipping."); + } + } + catch (Exception ex) + { + logger.Error(ex, $"Line {watch.LineNumber}: Error processing avatar ID '{watch.AvatarId}'"); + } + + // Throttle processing to avoid overwhelming the API + await Task.Delay(3000); + } + + + private static void CreateAvatarInfoForPrivate(TailgrabDBContext dBContext, string AvatarId) + { + var avatarInfo = new AvatarInfo + { + AvatarId = AvatarId, + UserId = "", + AvatarName = $"Unknown Avatar {AvatarId}", + ImageUrl = "", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + try + { + dBContext.Add(avatarInfo); + dBContext.SaveChanges(); + logger.Debug($"Adding fallback avatar record for {avatarInfo}"); + } + catch (Exception ex) + { + logger.Error($"Error adding fallback avatar record for {AvatarId}: {ex.Message}"); + } + } + + public static Avatar? FetchUpdateAvatarData(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, string AvatarId, AvatarInfo? dbAvatarInfo) + { + Avatar? avatarData = null; + try + { + // Avatar already exists in the database and was updated within the last 12 hours + System.Threading.Thread.Sleep(500); + avatarData = serviceRegistry.GetVRChatAPIClient().GetAvatarById(AvatarId); + if (avatarData != null) + { + if (dbAvatarInfo == null) + { + var avatarInfo = new AvatarInfo + { + AvatarId = avatarData.Id, + UserId = avatarData.AuthorId, + UserName = avatarData.AuthorName, + AvatarName = avatarData.Name, + ImageUrl = avatarData.ImageUrl, + CreatedAt = avatarData.CreatedAt, + UpdatedAt = DateTime.UtcNow + }; + + try + { + dBContext.Add(avatarInfo); + dBContext.SaveChanges(); + } + catch (Exception ex) + { + logger.Error($"Error adding avatar record for {AvatarId}: {ex.Message}"); + } + } + else + { + // Ensure entity is attached to the dbContext before updating to avoid Detached state errors + var entry = dBContext.Entry(dbAvatarInfo); + if (entry.State == Microsoft.EntityFrameworkCore.EntityState.Detached) + { + dBContext.Attach(dbAvatarInfo); + entry = dBContext.Entry(dbAvatarInfo); + } + + dbAvatarInfo.UserId = avatarData.AuthorId; + dbAvatarInfo.UserName = avatarData.AuthorName; + dbAvatarInfo.AvatarName = avatarData.Name; + dbAvatarInfo.ImageUrl = avatarData.ImageUrl; + dbAvatarInfo.CreatedAt = avatarData.CreatedAt; + dbAvatarInfo.UpdatedAt = DateTime.UtcNow; + + try + { + entry.State = Microsoft.EntityFrameworkCore.EntityState.Modified; + dBContext.SaveChanges(); + } + catch (Exception ex) + { + logger.Error($"Error updating avatar record for {AvatarId}: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + logger.Error($"Error fetching avatar: {ex.Message}"); + } + + return avatarData; + } + } + #endregion + + #region Avatar Queue Classes + internal class QueuedAvatarProcess(int priority, string avatarId) : IHavePriority + { + public int Priority { get; set; } = priority; + + public string AvatarId { get; set; } = avatarId; + } + + + public class QueuedAvatarWatch(int priority, string avatarId, AlertTypeEnum alertType, int lineNumber) : IHavePriority + { + public int Priority { get; set; } = priority; + + public string AvatarId { get; set; } = avatarId; + + public AlertTypeEnum AlertType { get; set; } = alertType; + + public int LineNumber { get; set; } = lineNumber; + } + + public class QueuedModeratedAvatarWatch(int priority, string avatarId, AlertTypeEnum alertType, int lineNumber) : IHavePriority + { + public int Priority { get; set; } = priority; + + public string AvatarId { get; set; } = avatarId; + + public AlertTypeEnum AlertType { get; set; } = alertType; + + public int LineNumber { get; set; } = lineNumber; } + #endregion } diff --git a/src/PlayerManagement/TailgrabPanel.xaml b/src/PlayerManagement/TailgrabPanel.xaml index 3dc95d9..b604db9 100644 --- a/src/PlayerManagement/TailgrabPanel.xaml +++ b/src/PlayerManagement/TailgrabPanel.xaml @@ -12,6 +12,47 @@ Title="Tailgrab Player Panel" + + + @@ -22,61 +63,24 @@ Title="Tailgrab Player Panel" - + - + - + - + - @@ -1318,9 +1300,19 @@ Title="Tailgrab Player Panel" - @@ -1335,9 +1327,19 @@ Title="Tailgrab Player Panel" - @@ -1355,9 +1357,19 @@ Title="Tailgrab Player Panel" - @@ -1372,9 +1384,19 @@ Title="Tailgrab Player Panel" - @@ -1389,9 +1411,19 @@ Title="Tailgrab Player Panel" - @@ -1409,9 +1441,19 @@ Title="Tailgrab Player Panel" - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +