diff --git a/BuildNumber.txt b/BuildNumber.txt new file mode 100644 index 0000000..1447642 --- /dev/null +++ b/BuildNumber.txt @@ -0,0 +1 @@ +1976 diff --git a/Migrations/20260129210247_V1.0.9.cs b/Migrations/20260129210247_V1.0.9.cs index ac91d06..fb363e5 100644 --- a/Migrations/20260129210247_V1.0.9.cs +++ b/Migrations/20260129210247_V1.0.9.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/Migrations/20260206180413_V1.0.10.cs b/Migrations/20260206180413_V1.0.10.cs index 8038fa0..68c5a8d 100644 --- a/Migrations/20260206180413_V1.0.10.cs +++ b/Migrations/20260206180413_V1.0.10.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/Migrations/20260228211854_V1.1.0.Designer.cs b/Migrations/20260228211854_V1.1.0.Designer.cs new file mode 100644 index 0000000..2e2a009 --- /dev/null +++ b/Migrations/20260228211854_V1.1.0.Designer.cs @@ -0,0 +1,157 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tailgrab.Models; + +#nullable disable + +namespace tailgrab.Migrations +{ + [DbContext(typeof(TailgrabDBContext))] + [Migration("20260228211854_V1.1.0")] + partial class V110 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("Tailgrab.Models.AvatarInfo", b => + { + b.Property("AvatarId") + .HasColumnType("TEXT"); + + b.Property("AlertType") + .HasColumnType("INTEGER"); + + b.Property("AvatarName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("AvatarId"); + + b.ToTable("AvatarInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.GroupInfo", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("AlertType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("createDate"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updateDate"); + + b.HasKey("GroupId"); + + b.ToTable("GroupInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.ImageEvaluation", b => + { + b.Property("InventoryId") + .HasColumnType("TEXT"); + + b.Property("Evaluation") + .HasColumnType("BLOB"); + + b.Property("IsIgnored") + .HasColumnType("INTEGER"); + + b.Property("LastDateTime") + .HasColumnType("TEXT"); + + b.Property("Md5checksum") + .HasColumnType("TEXT") + .HasColumnName("MD5Checksum"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("InventoryId"); + + b.ToTable("ImageEvaluation", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.ProfileEvaluation", b => + { + b.Property("Md5checksum") + .HasColumnType("TEXT") + .HasColumnName("MD5Checksum"); + + b.Property("Evaluation") + .HasColumnType("BLOB"); + + b.Property("IsIgnored") + .HasColumnType("INTEGER"); + + b.Property("LastDateTime") + .HasColumnType("TEXT"); + + b.Property("ProfileText") + .HasColumnType("BLOB"); + + b.HasKey("Md5checksum"); + + b.ToTable("ProfileEvaluation", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.UserInfo", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DateJoined") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("ElapsedMinutes") + .HasColumnType("REAL"); + + b.Property("LastProfileChecksum") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserInfo", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260228211854_V1.1.0.cs b/Migrations/20260228211854_V1.1.0.cs new file mode 100644 index 0000000..aef4eb9 --- /dev/null +++ b/Migrations/20260228211854_V1.1.0.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tailgrab.Migrations +{ + /// + public partial class V110 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsBOS", + table: "UserInfo"); + + migrationBuilder.RenameColumn( + name: "elapsedHours", + table: "UserInfo", + newName: "ElapsedMinutes"); + + migrationBuilder.RenameColumn( + name: "IsBOS", + table: "GroupInfo", + newName: "AlertType"); + + migrationBuilder.RenameColumn( + name: "IsBOS", + table: "AvatarInfo", + newName: "AlertType"); + + migrationBuilder.AddColumn( + name: "DateJoined", + table: "UserInfo", + type: "TEXT", + nullable: false, + defaultValue: new DateOnly(1, 1, 1)); + + migrationBuilder.AddColumn( + name: "LastProfileChecksum", + table: "UserInfo", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsIgnored", + table: "ProfileEvaluation", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsIgnored", + table: "ImageEvaluation", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "UserName", + table: "AvatarInfo", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DateJoined", + table: "UserInfo"); + + migrationBuilder.DropColumn( + name: "LastProfileChecksum", + table: "UserInfo"); + + migrationBuilder.DropColumn( + name: "IsIgnored", + table: "ProfileEvaluation"); + + migrationBuilder.DropColumn( + name: "IsIgnored", + table: "ImageEvaluation"); + + migrationBuilder.DropColumn( + name: "UserName", + table: "AvatarInfo"); + + migrationBuilder.RenameColumn( + name: "ElapsedMinutes", + table: "UserInfo", + newName: "elapsedHours"); + + migrationBuilder.RenameColumn( + name: "AlertType", + table: "GroupInfo", + newName: "IsBOS"); + + migrationBuilder.RenameColumn( + name: "AlertType", + table: "AvatarInfo", + newName: "IsBOS"); + + migrationBuilder.AddColumn( + name: "IsBOS", + table: "UserInfo", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/Migrations/20260228212237_V1.1.01.Designer.cs b/Migrations/20260228212237_V1.1.01.Designer.cs new file mode 100644 index 0000000..1b62eda --- /dev/null +++ b/Migrations/20260228212237_V1.1.01.Designer.cs @@ -0,0 +1,159 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tailgrab.Models; + +#nullable disable + +namespace tailgrab.Migrations +{ + [DbContext(typeof(TailgrabDBContext))] + [Migration("20260228212237_V1.1.01")] + partial class V1101 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("Tailgrab.Models.AvatarInfo", b => + { + b.Property("AvatarId") + .HasColumnType("TEXT"); + + b.Property("AlertType") + .HasColumnType("INTEGER"); + + b.Property("AvatarName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("AvatarId"); + + b.ToTable("AvatarInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.GroupInfo", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("AlertType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("createDate"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updateDate"); + + b.HasKey("GroupId"); + + b.ToTable("GroupInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.ImageEvaluation", b => + { + b.Property("InventoryId") + .HasColumnType("TEXT"); + + b.Property("Evaluation") + .HasColumnType("BLOB"); + + b.Property("IsIgnored") + .HasColumnType("INTEGER") + .HasColumnName("isIgnored"); + + b.Property("LastDateTime") + .HasColumnType("TEXT"); + + b.Property("Md5checksum") + .HasColumnType("TEXT") + .HasColumnName("MD5Checksum"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("InventoryId"); + + b.ToTable("ImageEvaluation", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.ProfileEvaluation", b => + { + b.Property("Md5checksum") + .HasColumnType("TEXT") + .HasColumnName("MD5Checksum"); + + b.Property("Evaluation") + .HasColumnType("BLOB"); + + b.Property("IsIgnored") + .HasColumnType("INTEGER") + .HasColumnName("isIgnored"); + + b.Property("LastDateTime") + .HasColumnType("TEXT"); + + b.Property("ProfileText") + .HasColumnType("BLOB"); + + b.HasKey("Md5checksum"); + + b.ToTable("ProfileEvaluation", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.UserInfo", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DateJoined") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("ElapsedMinutes") + .HasColumnType("REAL"); + + b.Property("LastProfileChecksum") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserInfo", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260228212237_V1.1.01.cs b/Migrations/20260228212237_V1.1.01.cs new file mode 100644 index 0000000..aed520b --- /dev/null +++ b/Migrations/20260228212237_V1.1.01.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tailgrab.Migrations +{ + /// + public partial class V1101 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "IsIgnored", + table: "ProfileEvaluation", + newName: "isIgnored"); + + migrationBuilder.RenameColumn( + name: "IsIgnored", + table: "ImageEvaluation", + newName: "isIgnored"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "isIgnored", + table: "ProfileEvaluation", + newName: "IsIgnored"); + + migrationBuilder.RenameColumn( + name: "isIgnored", + table: "ImageEvaluation", + newName: "IsIgnored"); + } + } +} diff --git a/Migrations/TailgrabDBContextModelSnapshot.cs b/Migrations/TailgrabDBContextModelSnapshot.cs index 3698cc8..4f7b67d 100644 --- a/Migrations/TailgrabDBContextModelSnapshot.cs +++ b/Migrations/TailgrabDBContextModelSnapshot.cs @@ -22,6 +22,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AvatarId") .HasColumnType("TEXT"); + b.Property("AlertType") + .HasColumnType("INTEGER"); + b.Property("AvatarName") .HasColumnType("TEXT"); @@ -31,16 +34,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ImageUrl") .HasColumnType("TEXT"); - b.Property("IsBos") - .HasColumnType("INTEGER") - .HasColumnName("IsBOS"); - b.Property("UpdatedAt") .HasColumnType("TEXT"); b.Property("UserId") .HasColumnType("TEXT"); + b.Property("UserName") + .HasColumnType("TEXT"); + b.HasKey("AvatarId"); b.ToTable("AvatarInfo", (string)null); @@ -51,6 +53,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GroupId") .HasColumnType("TEXT"); + b.Property("AlertType") + .HasColumnType("INTEGER"); + b.Property("CreatedAt") .HasColumnType("TEXT") .HasColumnName("createDate"); @@ -58,10 +63,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GroupName") .HasColumnType("TEXT"); - b.Property("IsBos") - .HasColumnType("INTEGER") - .HasColumnName("IsBOS"); - b.Property("UpdatedAt") .HasColumnType("TEXT") .HasColumnName("updateDate"); @@ -79,6 +80,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Evaluation") .HasColumnType("BLOB"); + b.Property("IsIgnored") + .HasColumnType("INTEGER") + .HasColumnName("isIgnored"); + b.Property("LastDateTime") .HasColumnType("TEXT"); @@ -103,6 +108,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Evaluation") .HasColumnType("BLOB"); + b.Property("IsIgnored") + .HasColumnType("INTEGER") + .HasColumnName("isIgnored"); + b.Property("LastDateTime") .HasColumnType("TEXT"); @@ -122,16 +131,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("DateJoined") + .HasColumnType("TEXT"); + b.Property("DisplayName") .HasColumnType("TEXT"); b.Property("ElapsedMinutes") - .HasColumnType("REAL") - .HasColumnName("elapsedHours"); + .HasColumnType("REAL"); - b.Property("IsBos") - .HasColumnType("INTEGER") - .HasColumnName("IsBOS"); + b.Property("LastProfileChecksum") + .HasColumnType("TEXT"); b.Property("UpdatedAt") .HasColumnType("TEXT"); diff --git a/README.md b/README.md index 67685ee..3d36f71 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,13 @@ VRChat Log Parser and Automation tool to help moderators manage trouble makers i Tailgrab will read the VRChat Local Game Log files in real time, parse them for events and then trigger actions based on the configuration of the application. The application is designed to be flexible and allow for a wide range of actions to be triggered based on the events that are parsed from the VRChat logs and alert the user to elements that may be less than honest. -[](./tailgrab_application.png) +[](./docs/tailgrab_application.png) ## Features - Shows a live feed of user in the current instance with their VRChat Avatar and UserID -- When in Furry Hideout, shows user's usage of Furry Hideout Pens -- Quick view of the user's historical avatar, pen, print, emoji and sticker usage in the current instance. -- AI powered insights on user Profile content. +- Quick view of the user's historical avatar, print, emoji and sticker usage in the current instance. +- AI powered insights on user Profile, Sticker, Emoji and Print content. - Quick reporting of User's Profile to the in game moderation instance -- AI powered insights on user Image Assets used. - Quick reporting of User's Images to the in game moderation instance - Copy Button that copies the user's VRChat User Id, Display name, Instance Stats and historical activity for pasting into your favorite moderation toolset. - Avatar Flagging based on user directed database. @@ -21,10 +19,50 @@ Tailgrab will read the VRChat Local Game Log files in real time, parse them for ## Installation -> [!NOTE] -> I am learing how to build a Installer for the appliction, but for now you will need to download the latest release and extract the zip file to a location of your choice on your Windows machine. Then you can run the ```tailgrab.exe``` application to start monitoring your VRChat instance. +> [!IMPORTANT] +> The new way is using the Installer/Uninstaller. This will move all your configuration and data files to the new location of ```{UserProfile}/AppData/Local/Tailgrab/```; +> if you have made changes to the: +> - ```./config.json``` +> - ```NLog.config``` -- Note that there is a change for logging write by 'Session Start Time' +> - ```pen-network-id.csv``` +> Then they will need to be move to the new location of ```{UserProfile}/AppData/Local/Tailgrab/``` after the installation. +> - If you have made changes to the ```./sounds/*``` you will need to move to the sounds to the ```{UserProfile}/AppData/Local/Tailgrab/sounds/``` after the installation. +> - There is new settings for alert sounds per type and severity, so you may want to configure those in the Config->Alerts. +> +> **Don't forget to save on each page and restart the application.** +> +> - The database file structure has changed in the new version, install the application and set up your new settings, then close the application and run the command line migration tool to load all the old data from the old database file to the new database file. +> ``` +> .\migration.exe D:\dev\TailGrab\bin\Release\net10.0-windows\win-x64\publish\data\avatars.sqlite +> Successfully validated database at: D:\dev\TailGrab\bin\Release\net10.0-windows\win-x64\publish\data\avatars.sqlite +> Migration to V1.1.0 +> Database path: C:\Users\{user}\AppData\Local\Tailgrab\data\tailgrab.db +> Database connection successful! +> Table 'AvatarInfo' has 19607 records. +> Migrated 0 records from 'AvatarInfo' to new database. +> Table 'GroupInfo' has 73529 records. +> Migrated 57616 records from 'GroupInfo' to new database. +> Table 'UserInfo' has 13608 records. +> Migrated 13345 records from 'UserInfo' to new database. +> Table 'ProfileEvaluation' has 9764 records. +> Migrated 9674 records from 'ProfileEvaluation' to new database. +> Table 'ImageEvaluation' has 705 records. +> `Migrated 675 records from 'ImageEvaluation' to new database. +> ``` +> ### New Install + +> [!IMPORTANT] +> Ensure you have extended logging enabled in VRChat by going to Steam > VR Chat > Settings (Gear Icon) > Properties. Set the following into the Lauch Options. +> +> [](./docs/SteamVRChatSettings.png) +> +>```--enable-sdk-log-levels --enable-debug-gui --enable-udon-debug-logging --log-debug-levels="Always;API;AssetBundleDownloadManager;ContentCreator;All;NetworkTransport;NetworkData;NetworkProcessing``` +> +> This will expose more information in the VRChat logs that TailGrab can parse and use to provide more insights and trigger actions based on the events that are happening in your VRChat instance. + + 1. Download the latest release of TailGrab from the [Latest Release](https://github.com/jlong23/Tailgrab/releases/latest) page. 1. Extract the downloaded zip file to a location of your choice on your Windows machine. 1. Run the ```tailgrab.exe``` application to start monitoring your VRChat instance. @@ -36,12 +74,40 @@ Tailgrab will read the VRChat Local Game Log files in real time, parse them for 1. Configure any new Secrets and other configuration values that may be needed for new features added since your last version. 1. Restart the application with ```tailgrab.exe``` to start monitoring your VRChat instance with the new version of TailGrab. + +### Where is everything now + +The Application Folder is now under ```\Program Files (x86)\Devious Fox Enterprises\Tailgrab```; this is where the application executable and all the support DLLs are stored. You can use the Uninstaller to remove the application as needed. + +Your configuration and data files are now stored under ```{UserProfile}/AppData/Local/Tailgrab/```; + +``` +...\AppData\ + Local\ + Tailgrab\ + config.json + pen-network-id.csv + NLog.config + sounds\ + alert_sound.wav + alert_sound_2.wav + alert_sound_3.wav + data\ + tailgrab.db + logs\ + tailgrab_2026-02-28_12-00-00.log +``` + ## Configuration [Application Config](./docs/Config_Application.md) for details on how to configure the application to connect to API services. [Config Line Handlers](./docs/Config_LineHandlers.md) for details on how to configure the application to respond to VRChat local game log events. ## Quick Usage + +> [!IMPORTANT] +> By default TailGrab will look for VRChat log files in the default location of ```{UserProfile}\AppData\LocalLow\VRChat\VRChat\```, and should pick up any log files that are created on the same date Tailgrab is being run. Meaning you can restart Tailgrab while VRC is running and rejoin the instance to repopulate the active players. If you do this in a low activity instance, aka homeworld; you may need to do the 'Rejoin' as soon as possible to ensure the pickup of the logfile. Tailgrab will pick all other log files for that day, but if there is no activity in the log since the startup from 15 minutes ago, then tailgrab will close and ignore the file for performance reasons. + Click the windows application or open a Powershell or Command Line prompt in your windows host, change directory to where ```tailgrab.exe``` has been extracted to and start it with: ```.\tailgrab.exe``` @@ -60,24 +126,71 @@ This will remove all stored configuration and secret values from the Windows Reg By default TailGrab will look for VRChat log files in the default location of: -```YourUserHome\AppData\LocalLow\VRChat\VRChat\``` +```\AppData\LocalLow\VRChat\VRChat\``` This can be overridden by passing the full path to the VRChat log files as the first argument to the application. -```.\\tailgrab.exe D:\MyVRChatLogs\``` +```.\tailgrab.exe -l D:\MyVRChatLogs\``` ### Watching TailGrab Application Logs -The TailGrab application will log it's internal operations to the ```./logs``` folder in the same directory as the application executable. Each run of the application will create a new log file with a timestamp in the filename. +The TailGrab application will log it's internal operations to the ```{UserProfile}/AppData/Local/Tailgrab/logs/``` folder in the same directory as the application executable. Each run of the application will create a new log file with a timestamp in the filename. If you want to watch the application logs in real time, you can use a tool like ```tail``` from Git Bash or ```Get-Content``` from Powershell session with the log filename. -```Get-Content -Path .\logs\tailgrab-2026-01-26.log -wait``` +```Get-Content -Path (Get-ChildItem -Path $HOME\AppData\Local\Tailgrab\logs\ -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName -Wait -Tail 20``` -## Usefull Tool Sets +### Bulk Editing of Database Files + +The TailGrab SQLite database will stored to the ```{UserProfile}/AppData/Local/Tailgrab/data/tailgrab.db``` path. + +#### Usefull Tool Sets DB Browser for SQLite - https://sqlitebrowser.org/ +##### Export SQL For GIST or Team Share + +Avatars: + +```SQL +SELECT + '"' || AvatarId || '","' || AvatarName || '","' || + CASE + WHEN alertType = 1 THEN 'Watch' + WHEN alertType = 2 THEN 'Nuisance' + WHEN alertType = 3 THEN 'Crasher' + ELSE 'NONE' + END || '"' AS GISTLine + FROM AvatarInfo + WHERE alertType > 0 + ORDER BY AvatarName +``` + +My Avatars GIST Export URL: +``` +https://gist.githubusercontent.com/jlong23/b4d0d55eaafeffe40e3cffd3da0b2e3b/raw/TG_Avatar.txt +``` + +Groups: + +```SQL +SELECT + '"' || GroupId || '","' || GroupName || '","' || + CASE + WHEN alertType = 1 THEN 'Watch' + WHEN alertType = 2 THEN 'Nuisance' + WHEN alertType = 3 THEN 'Crasher' + ELSE 'NONE' + END || '"' AS GISTLine + FROM GroupInfo + WHERE alertType > 0 + ORDER BY GroupName +``` + +My Groups GIST Export URL: +``` +https://gist.githubusercontent.com/jlong23/2b051df849cabb4da273eaf98225ae4e/raw/TG_Group.txt +``` ## Detail Documentation @@ -99,3 +212,6 @@ DB Browser for SQLite - https://sqlitebrowser.org/ [Config Tab, Secrets](./docs/Config_Application.md) Configure API Keys and other application settings. +[Config Tab, Alerts](./docs/Config_Alerts.md) Configure Alert Levels sounds and highlight colors. + +[Config Tab, Migrations](./docs/Config_Migrations.md) Migrate V1.0.9 Database to the new database. diff --git a/docs/Application_Tab_ActivePlayers.md b/docs/Application_Tab_ActivePlayers.md index 9fac523..2f94272 100644 --- a/docs/Application_Tab_ActivePlayers.md +++ b/docs/Application_Tab_ActivePlayers.md @@ -9,15 +9,9 @@ Below the Tab, the panel there is a search box that allows you to filter the lis Below that the list of active players in the instance. You can click on a player to view their profile and historical activity on the two bottom text boxes. The left box shows the player's profile and AI Evaluation, while the right box shows the historical avatar; emoji, sticker and print usage. The Column Header when clicked will sort the column; the default is "Instance Start" Decending. -The _**Code**_ column indicates an alert state for the player. If there are no alerts, the column will be blank. Any player that has any alert will be highlighted yellow. - -| Code | Alert Type | -| --- | --- | -| A | Avatar Alert | -| B | Bio/Profile Alert | -| G | Group Alert | -| P | Print Alert | -| E | Emoji/Sticker Alert | +The _**Alerts**_ column indicates an alert state for the player. If there are no alerts, the column will be blank. Any player that has any alert will be highlighted accoring to the sevarity of the alert. + +Format of the alerts contains [{Classification}/{Alert Type}] {Reason} where Classification is either "Profile", "Avatar", "Group", "Print" or "Emoji/Sticker". Alert Type is the severity level of the alert you assigned in the configuration areas. Prints, Stickers and Emojis are only flagged when there is a Ollama Key and the evaluation is done successfuly with the supplied prompt. The Copy Profile button allows you to copy the player's profile information to your clipboard, while the Report Profile button allows you to report the player for any inappropriate content using the VR Chat in-game reporting system. When you click the Report Profile button, it will open the a dialog mimicing the VR Chat reporting page as a model dialog with the player's information and reporting values pre-filled. diff --git a/docs/Config_Alerts.md b/docs/Config_Alerts.md new file mode 100644 index 0000000..8c4b7d1 --- /dev/null +++ b/docs/Config_Alerts.md @@ -0,0 +1,25 @@ +[Back](../README.md) +# Application Alerts + +The TailGrab Alerts configuration panel is on the "Config" tab and then the "Alerts" sub-tab. + +[](./tailgrab_tab_config_alerts.png) + +## Alert Sounds / Color Style + +Based on the three areas of interest, you can customize the alert sounds that are player. The first set are windows default alert sounds while the application will add any sound file (WAV, MP3 or OGG) that exist under the application installed directory under ```./sounds``` + +**Avatar Alert** - This sound is played when an Avatar is detected in the instance that flagged with a Severity Level, and each severity level with a sound and color selection. + +**Group Alert** - This sound is played when a user in the instance that flagged with a Severity Level, and each severity level with a sound and color selection. + +**Profile Alert** - This sound is played when a user in the instance has a profile or Image that is evaluated by the AI services to be of concern based on your custom prompt criteria. + +> [!NOTE] +> The Profile Alert Severity Levels are hard coded to: +> +> Harrassment & Bullying - **AlertTypeEnum.Nuisance** +> +> Sexual Content - **AlertTypeEnum.Nuisance** +> +> Self Harm - **AlertTypeEnum.Watch** \ No newline at end of file diff --git a/docs/Config_Application.md b/docs/Config_Application.md index 068421b..e2f7129 100644 --- a/docs/Config_Application.md +++ b/docs/Config_Application.md @@ -17,7 +17,7 @@ The fields are your Web User Name and Password for VRChat, and the 2 Factor Auth **Password** - This is your VRChat Password you use to log in to the VRChat website. -**2FA Key** - This is the 2 Factor Authentication Key that is generated when you set up 2 Factor Authentication on your VRChat account, this is used to generate the 2FA codes that are required to authenticate with the VRChat API. +**2FA Key** - This is the [2 Factor Authentication Key](https://docs.vrchat.com/docs/setup-2fa) that is generated when you set up 2 Factor Authentication on your VRChat account, this is used to generate the 2FA codes that are required to authenticate with the VRChat API. You can see the Key on setup when you see the QRCode there is a link **enter the key manually**; copy this code to a note for entry into Tailgrab. Lastpass Authenticator allows you to view this code with the edit site button. > [!IMPORTANT] > VRChat's API is not officially supported for third party applications, and may change/break at any time; User credentials are stored in an encrypted format in the Windows Registry and used only to gather needed information about users in the instance you are in. @@ -114,20 +114,9 @@ The default prompt is designed to look for potential PG13 violation, but you can | Harassment & Bullying | Any content that would be considered trolling based on Religion, Race or Sexual Orientation | | Self Harm | Any content that could be considered a cry for help | -> [!TIP] +> [!TIP] > The Classification names are set, but how you word the prompt can get the AI to be as leinent or as strict as you want, you can also add more classifications if you want, just make sure to include them in the prompt and have the AI respond with the classification on the first line of the response for the application to parse it correctly. You can also use the reasoning section to give you more context on why the AI classified it a certain way, this can be helpful when you are on the fence about a user and want to make a judgement call on whether to report them or not. > [!NOTE] - > The system puts the Prompt plus attached copy of the Image thumbnail for evaluation: - -## Alert Sounds - -Based on the three areas of interest, you can customize the alert sounds that are player. The first set are windows default alert sounds while the application will add any sound file (WAV, MP3 or OGG) that exist under the application installed directory under ```./sounds``` - -**Avatar Alert** - This sound is played when an Avatar is detected in the instance that is on the Ban On Sight list. - -**Group Alert** - This sound is played when a user in the instance is detected to be in a Group that is on the Ban On Sight list. - -**Profile Alert** - This sound is played when a user in the instance has a profile that is evaluated by the AI services to be of concern based on your custom prompt criteria. diff --git a/docs/Config_Migrations.md b/docs/Config_Migrations.md new file mode 100644 index 0000000..a59b037 --- /dev/null +++ b/docs/Config_Migrations.md @@ -0,0 +1,18 @@ +[Back](../README.md) +# Migration V1.0.9 to V1.1.0 + +Due to the new changes to the database structure and location of the application data, there is a migration process to move the existing data from the old database to the new database. The migration process is a one time process that will move the existing data from the old database to the new database. The migration process will also update the existing data to match the new database structure. + +[](./tailgrab_tab_config_migrations.png) + +## Important Post Process Changes + +The new structure converts the Boolean Yes/No fields to a Severity Level Enum with the following entities, by default the conversion will set all BOS Flagged records to Severity of "Watch" + +**Avatar Alert** - All BOS Flags set to "Watch" + +**Group Alert** - All BOS Flags set to "Watch" + +After selection of the database file from the original location, the migration process will automatically convert the existing data to match the new database structure and set the new Severity Level Enum to "Watch" for all records. After the migration process is complete, a dialog will be shown to the user to inform them of the successful migration and the new location of the database file. The user can then click "OK" to close the dialog and start using the application with the new database structure. + +[](./tailgrab_tab_config_migration_complete.png) \ No newline at end of file diff --git a/docs/SteamVRChatSettings.png b/docs/SteamVRChatSettings.png new file mode 100644 index 0000000..c4c6799 Binary files /dev/null and b/docs/SteamVRChatSettings.png differ diff --git a/docs/tailgrab_application.png b/docs/tailgrab_application.png index 13d6398..a79c588 100644 Binary files a/docs/tailgrab_application.png and b/docs/tailgrab_application.png differ diff --git a/docs/tailgrab_tab_active_players_elements.png b/docs/tailgrab_tab_active_players_elements.png index 39a0242..c949835 100644 Binary files a/docs/tailgrab_tab_active_players_elements.png and b/docs/tailgrab_tab_active_players_elements.png differ diff --git a/docs/tailgrab_tab_active_players_elements.xcf b/docs/tailgrab_tab_active_players_elements.xcf index b375c77..fbf6ee7 100644 Binary files a/docs/tailgrab_tab_active_players_elements.xcf and b/docs/tailgrab_tab_active_players_elements.xcf differ diff --git a/docs/tailgrab_tab_config_alerts.png b/docs/tailgrab_tab_config_alerts.png new file mode 100644 index 0000000..f30d723 Binary files /dev/null and b/docs/tailgrab_tab_config_alerts.png differ diff --git a/docs/tailgrab_tab_config_avatars.png b/docs/tailgrab_tab_config_avatars.png index d26d5cd..9fd39c4 100644 Binary files a/docs/tailgrab_tab_config_avatars.png and b/docs/tailgrab_tab_config_avatars.png differ diff --git a/docs/tailgrab_tab_config_groups.png b/docs/tailgrab_tab_config_groups.png index fc47ef6..35e883f 100644 Binary files a/docs/tailgrab_tab_config_groups.png and b/docs/tailgrab_tab_config_groups.png differ diff --git a/docs/tailgrab_tab_config_migration.png b/docs/tailgrab_tab_config_migration.png new file mode 100644 index 0000000..12641db Binary files /dev/null and b/docs/tailgrab_tab_config_migration.png differ diff --git a/docs/tailgrab_tab_config_migration_complete.png b/docs/tailgrab_tab_config_migration_complete.png new file mode 100644 index 0000000..0a20f7a Binary files /dev/null and b/docs/tailgrab_tab_config_migration_complete.png differ diff --git a/docs/tailgrab_tab_config_users.png b/docs/tailgrab_tab_config_users.png index 9082add..54ca2dc 100644 Binary files a/docs/tailgrab_tab_config_users.png and b/docs/tailgrab_tab_config_users.png differ diff --git a/docs/tailgrab_tab_configuration.png b/docs/tailgrab_tab_configuration.png index 25489a3..ad57adc 100644 Binary files a/docs/tailgrab_tab_configuration.png and b/docs/tailgrab_tab_configuration.png differ diff --git a/docs/tailgrab_tab_past_players_elements.png b/docs/tailgrab_tab_past_players_elements.png new file mode 100644 index 0000000..c099426 Binary files /dev/null and b/docs/tailgrab_tab_past_players_elements.png differ diff --git a/src/Actions/Actions.cs b/src/Actions/Actions.cs index ff62d5f..c0a160c 100644 --- a/src/Actions/Actions.cs +++ b/src/Actions/Actions.cs @@ -50,14 +50,14 @@ public KeystrokesAction(string windowTitle, string keys) WindowTitle = windowTitle; Keys = keys; - logger.Warn($"Added KeystrokesAction: Window Title: '{WindowTitle}' with Keys: {Keys}."); + logger.Info($"Added KeystrokesAction: Window Title: '{WindowTitle}' with Keys: {Keys}."); } public void PerformAction() { if (WindowTitle == null || Keys == null) { - logger.Warn($"KeystrokesAction: Window Title: '{WindowTitle}' or Keys: {Keys} not supplied."); + logger.Info($"KeystrokesAction: Window Title: '{WindowTitle}' or Keys: {Keys} not supplied."); return; } @@ -66,7 +66,7 @@ public void PerformAction() var procs = Process.GetProcessesByName(WindowTitle); if (procs == null || procs.Length == 0) { - logger.Warn($"KeystrokesAction: Process: '{WindowTitle}' not found."); + logger.Info($"KeystrokesAction: Process: '{WindowTitle}' not found."); return; } @@ -79,13 +79,13 @@ public void PerformAction() if (hWnd == IntPtr.Zero) { - logger.Warn($"KeystrokesAction: Process: '{WindowTitle}' Could not find a main window for the process."); + logger.Info($"KeystrokesAction: Process: '{WindowTitle}' Could not find a main window for the process."); return; } if (!BringWindowToForeground(hWnd)) { - logger.Warn($"KeystrokesAction: Process: '{WindowTitle}' Could not bring the window to the foreground."); + logger.Info($"KeystrokesAction: Process: '{WindowTitle}' Could not bring the window to the foreground."); return; } @@ -252,7 +252,7 @@ public OSCAction(string parameterName, OscType type, string value) OscTypeValue = type; Value = value; - logger.Warn($"Added OSCAction: Parameter: '{ParameterName}'; Type: {OscTypeValue}; Value: {Value}."); + logger.Info($"Added OSCAction: Parameter: '{ParameterName}'; Type: {OscTypeValue}; Value: {Value}."); } @@ -305,7 +305,7 @@ public TTSAction(string text, int volume, int rate) Volume = volume; Rate = rate; - logger.Warn($"Added TTSAction: Parameter: '{Text}'; Volume: {Volume}; Rate: {Rate}."); + logger.Info($"Added TTSAction: Parameter: '{Text}'; Volume: {Volume}; Rate: {Rate}."); } diff --git a/src/AvatarManagement/AvatarManagement.cs b/src/AvatarManagement/AvatarManagement.cs index 89d8992..321a90b 100644 --- a/src/AvatarManagement/AvatarManagement.cs +++ b/src/AvatarManagement/AvatarManagement.cs @@ -1,6 +1,7 @@ using ConcurrentPriorityQueue.Core; using Microsoft.EntityFrameworkCore; using NLog; +using Polly; using Tailgrab.Clients.Ollama; using Tailgrab.Common; using Tailgrab.Models; @@ -33,7 +34,7 @@ public AvatarManagementService(ServiceRegistry serviceRegistry) public void AddAvatar(AvatarInfo avatar) { try - { + { _serviceRegistry.GetDBContext().AvatarInfos.Add(avatar); _serviceRegistry.GetDBContext().SaveChanges(); } @@ -87,21 +88,27 @@ private void EnqueueAvatarForCheck(string avatarId) { if ((DateTime.UtcNow - dateTime).TotalMinutes < 60) { - logger.Debug($"Skipping adding avatar {avatarId} as it was recently processed."); return; } } recentlyProcessedAvatars.Add(avatarId, DateTime.UtcNow); - var queuedItem = new QueuedAvatarProcess - { - AvatarId = avatarId, - Priority = 1 - }; - + 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) { @@ -129,7 +136,8 @@ public void GetAvatarsFromUser(string userId, string avatarName) ImageUrl = avatar.ImageUrl, CreatedAt = avatar.CreatedAt, UpdatedAt = DateTime.UtcNow, - IsBos = false + AlertType = AlertTypeEnum.None, + UserName = avatar.AuthorName }; AddAvatar(avatarInfo); @@ -137,6 +145,7 @@ public void GetAvatarsFromUser(string userId, string avatarName) else { dbAvatarInfo.UserId = avatar.AuthorId; + dbAvatarInfo.UserName = avatar.AuthorName; dbAvatarInfo.AvatarName = avatar.Name; dbAvatarInfo.ImageUrl = avatar.ImageUrl; dbAvatarInfo.CreatedAt = avatar.CreatedAt; @@ -156,22 +165,23 @@ public void CompactDatabase() _serviceRegistry.GetDBContext().Database.ExecuteSqlRaw("VACUUM;"); } - internal bool CheckAvatarByName(string avatarName) + internal AvatarInfo? CheckAvatarByName(string avatarName) { var bannedAvatars = _serviceRegistry.GetDBContext().AvatarInfos - .Where(b => b.AvatarName != null && b.AvatarName.Equals(avatarName) && b.IsBos) - .OrderBy(b => b.CreatedAt) + .Where(b => b.AvatarName != null && b.AvatarName.Equals(avatarName) && b.AlertType > 0) + .OrderByDescending(b => b.AlertType) .ToList(); if (bannedAvatars.Count > 0) { - string? soundSetting = ConfigStore.LoadSecret(Common.Common.Registry_Alert_Avatar) ?? "Hand"; - SoundManager.PlaySound(soundSetting); + // 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 true; + return bannedAvatars[0]; } - return false; + return null; } public static async Task AvatarCheckTask(ConcurrentPriorityQueue, int> priorityQueue, ServiceRegistry serviceRegistry) @@ -184,38 +194,19 @@ public static async Task AvatarCheckTask(ConcurrentPriorityQueue= DateTime.UtcNow.AddHours(-24))) - { - updateNeeded = true; - } - - if (updateNeeded) - { - Avatar? avatarData = FetchUpdateAvatarData(serviceRegistry, dBContext, item.AvatarId, dbAvatarInfo); - - if (avatarData == null && dbAvatarInfo == null) - { - CreateAvatarInfoForPrivate(dBContext, item.AvatarId); - } - - // Wait for a short period before checking the queue again - await Task.Delay(1000); - } + await UpdateAmpAvatarRecord(serviceRegistry, dBContext, item.AvatarId); } - catch (Exception ex) + else if (result.Value is QueuedAvatarWatch item2) + { + await UpdateWatchedAvatarRecord(serviceRegistry, dBContext, item2); + } + else if (result.Value is QueuedModeratedAvatarWatch item3) { - logger.Error(ex, $"Error fetching user profile for userId: {item.AvatarId}"); + await UpdateModeratedAvatarRecord(serviceRegistry, dBContext, item3); } } else @@ -229,6 +220,126 @@ public static async Task AvatarCheckTask(ConcurrentPriorityQueue= 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 @@ -238,8 +349,7 @@ private static void CreateAvatarInfoForPrivate(TailgrabDBContext dBContext, stri AvatarName = $"Unknown Avatar {AvatarId}", ImageUrl = "", CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - IsBos = false + UpdatedAt = DateTime.UtcNow }; try @@ -270,11 +380,11 @@ private static void CreateAvatarInfoForPrivate(TailgrabDBContext dBContext, stri { AvatarId = avatarData.Id, UserId = avatarData.AuthorId, + UserName = avatarData.AuthorName, AvatarName = avatarData.Name, ImageUrl = avatarData.ImageUrl, CreatedAt = avatarData.CreatedAt, - UpdatedAt = DateTime.UtcNow, - IsBos = false + UpdatedAt = DateTime.UtcNow }; try @@ -298,6 +408,7 @@ private static void CreateAvatarInfoForPrivate(TailgrabDBContext dBContext, stri } dbAvatarInfo.UserId = avatarData.AuthorId; + dbAvatarInfo.UserName = avatarData.AuthorName; dbAvatarInfo.AvatarName = avatarData.Name; dbAvatarInfo.ImageUrl = avatarData.ImageUrl; dbAvatarInfo.CreatedAt = avatarData.CreatedAt; @@ -326,10 +437,53 @@ private static void CreateAvatarInfoForPrivate(TailgrabDBContext dBContext, stri 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 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 11e5e9c..d2902d0 100644 --- a/src/Clients/Ollama/Ollama.cs +++ b/src/Clients/Ollama/Ollama.cs @@ -1,5 +1,4 @@ using ConcurrentPriorityQueue.Core; -using Microsoft.EntityFrameworkCore; using NLog; using OllamaSharp; using OllamaSharp.Models; @@ -82,23 +81,22 @@ public void CheckUserProfile(string userId) } - public static async Task ProfileCheckTask(ConcurrentPriorityQueue, int> priorityQueue, ServiceRegistry serviceRegistry) { - string? ollamaCloudKey = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Key); + string? ollamaCloudKey = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Key); OllamaApiClient? ollamaApi = null; if (ollamaCloudKey is null) { System.Windows.MessageBox.Show("Ollama API Credentials are not set.\nThis is not nessasary for limited operation, the Profiles will not be profileText.\nOtherwise use the Config / Secrets tab to update credenials and restart Tailgrab.", "Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); - } + } else { - string ollamaEndpoint = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Endpoint) ?? Tailgrab.Common.Common.Default_Ollama_API_Endpoint; + string ollamaEndpoint = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Endpoint) ?? CommonConst.Default_Ollama_API_Endpoint; HttpClient client = new HttpClient(); client.BaseAddress = new Uri(ollamaEndpoint); client.DefaultRequestHeaders.Add("Authorization", "Bearer " + ollamaCloudKey); ollamaApi = new OllamaApiClient(client); - string? ollamaModel = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Model) ?? Tailgrab.Common.Common.Default_Ollama_API_Model; + string? ollamaModel = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Model) ?? CommonConst.Default_Ollama_API_Model; ollamaApi.SelectedModel = ollamaModel; } @@ -110,15 +108,17 @@ public static async Task ProfileCheckTask(ConcurrentPriorityQueue userGroups = serviceRegistry.GetVRChatAPIClient().GetProfileGroups(item.UserId); + string fullProfile = $"DisplayName: {profile.DisplayName}\nStatusDesc: {profile.StatusDescription}\nPronowns: {profile.Pronouns}\nProfileBio: {profile.Bio}\n"; item.UserBio = fullProfile; + serviceRegistry.GetPlayerManager().UpdatePlayerUserFromVRCProfile(profile, item.MD5Hash); await GetUserGroupInformation(serviceRegistry, dBContext, userGroups, item); // Wait for a short period before checking the queue again @@ -150,7 +150,6 @@ public static async Task ProfileCheckTask(ConcurrentPriorityQueue GetUserGroupInformation(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, List userGroups, QueuedProcess item) { logger.Debug($"Processing User Group subscription for userId: {item.UserId}"); - bool isSuspectGroup = false; - string? watchedGroups = string.Empty; - foreach (LimitedUserGroups group in userGroups) + Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId ?? string.Empty); + if (player != null) { - GroupInfo? groupInfo = dBContext.GroupInfos.Find(group.GroupId); - if (groupInfo == null) + AlertTypeEnum maxAlertType = AlertTypeEnum.None; + foreach (LimitedUserGroups group in userGroups) { - groupInfo = new GroupInfo + GroupInfo? groupInfo = dBContext.GroupInfos.Find(group.GroupId); + if (groupInfo == null) { - GroupId = group.GroupId, - GroupName = group.Name, - IsBos = false, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - dBContext.GroupInfos.Add(groupInfo); - dBContext.SaveChanges(); - } - else - { - groupInfo.GroupName = group.Name; - dBContext.GroupInfos.Update(groupInfo); - dBContext.SaveChanges(); - - if (groupInfo.IsBos) + groupInfo = new GroupInfo + { + GroupId = group.GroupId, + GroupName = group.Name, + AlertType = AlertTypeEnum.None, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + dBContext.GroupInfos.Add(groupInfo); + dBContext.SaveChanges(); + } + else { - watchedGroups = string.Concat( watchedGroups, " " + groupInfo.GroupName ); - isSuspectGroup = true; + groupInfo.GroupName = group.Name; + dBContext.GroupInfos.Update(groupInfo); + dBContext.SaveChanges(); + + 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?.AddAlertMessage(AlertClassEnum.Group, groupInfo.AlertType, groupInfo.GroupName); + maxAlertType = maxAlertType < groupInfo.AlertType ? groupInfo.AlertType : maxAlertType; + } } } - } - if (isSuspectGroup) - { - Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId ?? string.Empty); - if (player != null) + if (player != null && player.IsWatched) { - player.IsGroupWatch = true; - player.PenActivity = watchedGroups; - serviceRegistry.GetPlayerManager().AddPlayerEventByUserId(item.UserId ?? string.Empty, PlayerEvent.EventType.GroupWatch, $"User is member of watched group(s): {watchedGroups}"); + SoundManager.PlayAlertSound(CommonConst.Group_Alert_Key, maxAlertType); + return true; } - - string? soundSetting = ConfigStore.LoadSecret(Common.Common.Registry_Alert_Group) ?? "Hand"; - SoundManager.PlaySound(soundSetting); } - - return isSuspectGroup; + return false; } - private async static void GetEvaluationFromCloud(OllamaApiClient ollamaApi, ServiceRegistry serviceRegistry, QueuedProcess item ) + private async static void GetEvaluationFromCloud(OllamaApiClient ollamaApi, ServiceRegistry serviceRegistry, QueuedProcess item) { - string? ollamaPrompt = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Prompt); + string? ollamaPrompt = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Prompt); GenerateRequest request = new GenerateRequest { Model = ollamaApi.SelectedModel, - Prompt = string.Concat( ollamaPrompt ?? Tailgrab.Common.Common.Default_Ollama_API_Prompt, item.UserBio ?? string.Empty ), + Prompt = string.Concat(ollamaPrompt ?? CommonConst.Default_Ollama_API_Prompt, item.UserBio ?? string.Empty), Stream = false }; @@ -248,15 +243,7 @@ await ollamaApi.GenerateAsync(request).StreamToEndAsync(responseTask => player.UserBio = item.UserBio; player.AIEval = response; - string? profileWatch = EvaluateProfile(player.AIEval); - if (profileWatch != null) - { - player.IsProfileWatch = true; - player.PenActivity = $"Bio: {profileWatch}"; - serviceRegistry.GetPlayerManager().AddPlayerEventByUserId(item.UserId ?? string.Empty, PlayerEvent.EventType.ProfileWatch, $"User profile was flagged by the AI : {profileWatch}"); - serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); - } - serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); + ProfileViewUpdate(serviceRegistry, player); } }); } @@ -264,6 +251,9 @@ await ollamaApi.GenerateAsync(request).StreamToEndAsync(responseTask => { logger.Error(ex, $"Error processing Ollama request for userId: {item.UserId} - {ex.Message}"); } + + // Wait for a short period before checking the queue again + await Task.Delay(5000); } private static void GetEvaluationFromStore(ServiceRegistry serviceRegistry, ProfileEvaluation evaluated, string? userId) @@ -276,14 +266,8 @@ private static void GetEvaluationFromStore(ServiceRegistry serviceRegistry, Prof { player.AIEval = System.Text.Encoding.UTF8.GetString(evaluated.Evaluation); player.UserBio = System.Text.Encoding.UTF8.GetString(evaluated.ProfileText); - string? profileWatch = EvaluateProfile(player.AIEval); - if (profileWatch != null) - { - player.IsProfileWatch = true; - player.PenActivity = profileWatch; - serviceRegistry.GetPlayerManager().AddPlayerEventByUserId(userId ?? string.Empty, PlayerEvent.EventType.ProfileWatch, $"User profile was flagged by the AI : {profileWatch}"); - } - serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); + + ProfileViewUpdate(serviceRegistry, player); logger.Debug($"User profile already processed for userId: {userId}"); } else @@ -298,6 +282,32 @@ private static void GetEvaluationFromStore(ServiceRegistry serviceRegistry, Prof } } + private static void ProfileViewUpdate(ServiceRegistry serviceRegistry, Player player) + { + string? profileWatch = EvaluateProfile(player.AIEval); + if (profileWatch != null) + { + switch (profileWatch) + { + case "Harrassment & Bullying": + player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Nuisance, "Hate"); + SoundManager.PlayAlertSound(CommonConst.Profile_Alert_Key, AlertTypeEnum.Nuisance); + break; + case "Explicit Sexual": + player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Nuisance, "Sexual"); + SoundManager.PlayAlertSound(CommonConst.Profile_Alert_Key, AlertTypeEnum.Nuisance); + break; + case "Self Harm": + player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Watch, "Self-Harm"); + SoundManager.PlayAlertSound(CommonConst.Profile_Alert_Key, AlertTypeEnum.Watch); + break; + } + + serviceRegistry.GetPlayerManager().AddPlayerEventByUserId(player.UserId ?? string.Empty, + PlayerEvent.EventType.ProfileWatch, $"User profile was flagged by the AI : {profileWatch}"); + } + serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); + } private static string? EvaluateProfile(string? profileText) { @@ -337,20 +347,20 @@ private static bool CheckLines(string input, string knownString) } #region Image Classification - internal async Task ClassifyImageList(string userId, string assetId, List imageUrlList) + internal async Task ClassifyImageList(string userId, string assetId, List imageUrlList) { logger.Debug($"Classifying image from Asset: {assetId} URI: {imageUrlList.ToArray()}"); try { - string? ollamaCloudKey = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Key); + string? ollamaCloudKey = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Key); if (ollamaCloudKey == null) { logger.Warn("Ollama API credentials are not set"); return null; } - string ollamaEndpoint = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Endpoint) ?? Tailgrab.Common.Common.Default_Ollama_API_Endpoint; + string ollamaEndpoint = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Endpoint) ?? CommonConst.Default_Ollama_API_Endpoint; ImageReference? imageReference = await _serviceRegistry.GetVRChatAPIClient().GetImageReference(assetId, userId, imageUrlList); if (imageReference != null) @@ -367,14 +377,14 @@ private static bool CheckLines(string input, string knownString) using (OllamaApiClient ollamaApi = new OllamaApiClient(ollamaHttpClient)) { - string? ollamaModel = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Model) ?? Tailgrab.Common.Common.Default_Ollama_API_Model; + string? ollamaModel = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Model) ?? CommonConst.Default_Ollama_API_Model; ollamaApi.SelectedModel = ollamaModel; - string? ollamaPrompt = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Image_Prompt); + string? ollamaPrompt = ConfigStore.LoadSecret(CommonConst.Registry_Ollama_API_Image_Prompt); GenerateRequest request = new GenerateRequest { Model = ollamaApi.SelectedModel, - Prompt = ollamaPrompt ?? Tailgrab.Common.Common.Default_Ollama_API_Image_Prompt, + Prompt = ollamaPrompt ?? CommonConst.Default_Ollama_API_Image_Prompt, Images = imageReference.Base64Data.ToArray(), Stream = false }; @@ -382,16 +392,16 @@ private static bool CheckLines(string input, string knownString) var response = await ollamaApi.GenerateAsync(request).StreamToEndAsync(); logger.Debug($"Image classified for InventoryId: {imageReference.InventoryId} as {response?.Response}"); - SaveImageEvaluation(imageReference, response?.Response); + imageEvaluation = SaveImageEvaluation(imageReference, response?.Response); - return response?.Response; + return imageEvaluation; } } } else { logger.Debug($"Image already classified for AssetId : {imageReference.InventoryId}"); - return System.Text.Encoding.UTF8.GetString(imageEvaluation.Evaluation); + return imageEvaluation; } } } @@ -412,10 +422,11 @@ private static bool CheckLines(string input, string knownString) logger.Debug($"Image already reviewed for InventoryId: {imageReference.InventoryId}"); return evaluated; } + return null; } - private void SaveImageEvaluation(ImageReference imageReference, string? response) + private ImageEvaluation? SaveImageEvaluation(ImageReference imageReference, string? response) { if (response != null) { @@ -425,12 +436,16 @@ private void SaveImageEvaluation(ImageReference imageReference, string? response UserId = imageReference.UserId, Md5checksum = imageReference.Md5Hash, Evaluation = System.Text.Encoding.UTF8.GetBytes(response ?? string.Empty), - LastDateTime = DateTime.UtcNow + LastDateTime = DateTime.UtcNow, + IsIgnored = false }; TailgrabDBContext dBContext = _serviceRegistry.GetDBContext(); dBContext.Add(evaluation); dBContext.SaveChanges(); + return evaluation; } + + return null; } #endregion } diff --git a/src/Clients/VRChat/VRChat.cs b/src/Clients/VRChat/VRChat.cs index 02e0a6d..dfafd88 100644 --- a/src/Clients/VRChat/VRChat.cs +++ b/src/Clients/VRChat/VRChat.cs @@ -16,96 +16,114 @@ namespace Tailgrab.Clients.VRChat public class VRChatClient { private const string URI_VRC_BASE_API = "https://api.vrchat.cloud"; - public static string UserAgent = "Tailgrab/1.0.7"; + public static string UserAgent = "Tailgrab/1.1.0"; public static Logger logger = LogManager.GetCurrentClassLogger(); private IVRChat? _vrchat; public async Task Initialize() { - string? username = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_VRChat_Web_UserName); - string? password = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_VRChat_Web_Password); - string? twoFactorSecret = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_VRChat_Web_2FactorKey); + string? username = ConfigStore.LoadSecret(CommonConst.Registry_VRChat_Web_UserName); + string? password = ConfigStore.LoadSecret(CommonConst.Registry_VRChat_Web_Password); + string? twoFactorSecret = ConfigStore.LoadSecret(CommonConst.Registry_VRChat_Web_2FactorKey); - if (username is null || password is null || twoFactorSecret is null) - { - System.Windows.MessageBox.Show("VR Chat Web API Credentials are not set yet, use the Config / Secrets tab to update credenials and restart Tailgrab.", "Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); - return; - } - - string cookiePath = Path.Combine(Directory.GetCurrentDirectory(), "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: "Jarvis", version: "1.0.0", contact: "jlong@rabbitearsvideoproduction.com"); - - if (loadedCookies != null && loadedCookies.Count > 0) + // Persist cookies to disk (cookies.json) for reuse + try { - Console.WriteLine("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) + if (username is null || password is null || twoFactorSecret is null) { - var result = withCookiesMethod.Invoke(builder, new object[] { loadedCookies }); - if (result is VRChatClientBuilder cb) - { - builder = cb; - } - else + System.Windows.MessageBox.Show("VR Chat Web API Credentials are not set yet, use the Config / Secrets tab to update credenials and restart Tailgrab.", "Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + 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) + { + 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) { - // fallback to username/password if return type not expected - builder = builder.WithUsername(username).WithPassword(password); + if (cookie.Name == "auth") + { + authCookieValue = cookie.Value; + } + else if (cookie.Name == "twoFactorAuth") + { + twoFactorCookieValue = cookie.Value; + } } + _vrchat = builder.WithAuthCookie(authCookieValue, twoFactorCookieValue).Build(); } else { - // no WithCookies method; fall back to username/password - builder = builder.WithUsername(username).WithPassword(password); + logger.Info("No valid cookies found on disk, falling back to username/password authentication."); + _vrchat = builder.WithUsername(username).WithPassword(password).Build(); } - } - else - { - Console.WriteLine("No valid cookies found on disk, falling back to username/password authentication."); - builder = builder.WithUsername(username).WithPassword(password); - } - _vrchat = builder.Build(); - - var response = await _vrchat.Authentication.GetCurrentUserAsync(); - if (response.RequiresTwoFactorAuth.Contains("emailOtp")) - { - Console.WriteLine("An verification code was sent to your email address!"); - Console.Write("Enter code: "); - //string code = Console.ReadLine(); - string code = "1234"; - var otpResponse = await _vrchat.Authentication.Verify2FAEmailCodeAsync(new TwoFactorEmailCode(code)); - } - else if (response.RequiresTwoFactorAuth.Contains("totp")) - { - var totp = new Totp(Base32Encoding.ToBytes(twoFactorSecret)); - string code = totp.ComputeTotp(); - - var otpResponse = await _vrchat.Authentication.Verify2FAAsync(new TwoFactorAuthCode(code)); - } + var response = await _vrchat.Authentication.GetCurrentUserAsync(); + if (response != null && response is CurrentUser ) + { + if (response.RequiresTwoFactorAuth != null && response.RequiresTwoFactorAuth.Contains("emailOtp")) + { + 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)); + } + else if (response.RequiresTwoFactorAuth != null && response.RequiresTwoFactorAuth.Contains("totp")) + { + var totp = new Totp(Base32Encoding.ToBytes(twoFactorSecret)); + string code = totp.ComputeTotp(); - var currentUser = await _vrchat.Authentication.GetCurrentUserAsync(); - Console.WriteLine($"Logged in as \"{currentUser.DisplayName}\""); + var otpResponse = await _vrchat.Authentication.Verify2FAAsync(new TwoFactorAuthCode(code)); + } - var cookies = _vrchat.GetCookies(); + var currentUser = await _vrchat.Authentication.GetCurrentUserAsync(); + logger.Info($"Logged in as \"{currentUser.DisplayName}\""); + var cookies = _vrchat.GetCookies(); - // Persist cookies to disk (cookies.json) for reuse - try - { - SaveCookiesToFile(cookiePath, cookies); + SaveCookiesToFile(cookiePath, cookies); + } } catch (Exception ex) { - Console.WriteLine($"Failed to save cookies to '{cookiePath}': {ex.Message}"); + 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); } } @@ -225,7 +243,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 { @@ -246,7 +264,7 @@ public List GetProfileGroups(string userId) var json = await response.Content.ReadAsStringAsync(); item = JsonConvert.DeserializeObject(json); - + if (item != null) { logger.Info($"Fetched inventory item: {item.Name} ({item.ItemType}) for user {userId}"); @@ -260,7 +278,7 @@ public List GetProfileGroups(string userId) return item; } - public async Task GetImageReference(string inventoryId, string userId, List imageUrlList ) + public async Task GetImageReference(string inventoryId, string userId, List imageUrlList) { try { @@ -289,10 +307,10 @@ public List GetProfileGroups(string userId) string md5Hash = string.Empty; List imageList = new List(); int imageCount = 0; - foreach ( string imageUrl in imageUrlList) + foreach (string imageUrl in imageUrlList) { byte[] contentBytes = await httpClient.GetByteArrayAsync(imageUrl); - if( imageCount == 0) + if (imageCount == 0) { md5Hash = Checksum.CreateMD5(contentBytes); } @@ -352,7 +370,7 @@ public List GetProfileGroups(string userId) logger.Error($"Error fetching avatar: {ex.Message}"); } - return printInfo; + return printInfo; } public List GetAvatarModerations() @@ -512,7 +530,7 @@ internal async Task SubmitModerationReportAsync(ModerationReportPayload rp using HttpClient httpClient = new HttpClient(handler); httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); - // Download the image + // Submit the moderation report HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{URI_VRC_BASE_API}/api/1/moderationReports", rpt); string responseContent = await response.Content.ReadAsStringAsync(); logger.Debug($"Response from submitting moderation report for content {rpt.ContentId}: {responseContent}"); @@ -529,6 +547,50 @@ internal async Task SubmitModerationReportAsync(ModerationReportPayload rp } } + #region Non Public Helper Types + private class SerializableCookie + { + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Domain { get; set; } = string.Empty; + public string Path { get; set; } = "/"; + public DateTime Expires { get; set; } = DateTime.MinValue; + public bool Secure { get; set; } + public bool HttpOnly { get; set; } + + public Cookie ToCookie() + { + var cookie = new Cookie(Name, Value, Path, Domain) + { + Secure = Secure, + HttpOnly = HttpOnly + }; + + if (Expires != DateTime.MinValue) + { + cookie.Expires = Expires; + } + + return cookie; + } + + public static SerializableCookie FromCookie(Cookie c) + { + return new SerializableCookie + { + Name = c.Name, + Value = c.Value, + Domain = c.Domain ?? string.Empty, + Path = c.Path ?? "/", + Expires = c.Expires, + Secure = c.Secure, + HttpOnly = c.HttpOnly + }; + } + } + #endregion + + #region Non Public JSON Serializable Types public class AvatarModerationItem { [JsonProperty("avatarModerationType")] @@ -650,7 +712,7 @@ public class ModerationReportPayload { [JsonProperty("type")] public string Type { get; set; } = string.Empty; - + [JsonProperty("category")] public string Category { get; set; } = string.Empty; @@ -672,7 +734,7 @@ public class ModerationReportDetails [JsonProperty("instanceType")] public string InstanceType { get; set; } = string.Empty; - [JsonProperty("instanceAgeGated")] + [JsonProperty("instanceAgeGated")] public bool InstanceAgeGated { get; set; } [JsonProperty("userInSameInstance")] @@ -682,47 +744,6 @@ public class ModerationReportDetails public string HolderId { get; set; } = string.Empty; } - private class SerializableCookie - { - public string Name { get; set; } = string.Empty; - public string Value { get; set; } = string.Empty; - public string Domain { get; set; } = string.Empty; - public string Path { get; set; } = "/"; - public DateTime Expires { get; set; } = DateTime.MinValue; - public bool Secure { get; set; } - public bool HttpOnly { get; set; } - - public Cookie ToCookie() - { - var cookie = new Cookie(Name, Value, Path, Domain) - { - Secure = Secure, - HttpOnly = HttpOnly - }; - - if (Expires != DateTime.MinValue) - { - cookie.Expires = Expires; - } - - return cookie; - } - - public static SerializableCookie FromCookie(Cookie c) - { - return new SerializableCookie - { - Name = c.Name, - Value = c.Value, - Domain = c.Domain ?? string.Empty, - Path = c.Path ?? "/", - Expires = c.Expires, - Secure = c.Secure, - HttpOnly = c.HttpOnly - }; - } - } - public class PrintInfo { [JsonProperty("authorId")] @@ -740,9 +761,9 @@ public class PrintInfo [JsonProperty("timestamp")] public string Timestamp { get; set; } = string.Empty; [JsonProperty("worldId")] - public string WorldId { get; set; } = string.Empty; + public string WorldId { get; set; } = string.Empty; [JsonProperty("worldName")] - public string WorldName { get; set; } = string.Empty; + public string WorldName { get; set; } = string.Empty; [JsonProperty("files")] public PrintFileInfo FileInfo { get; set; } = new PrintFileInfo(); } @@ -754,5 +775,6 @@ public class PrintFileInfo [JsonProperty("image")] public string ImageUrl { get; set; } = string.Empty; } + #endregion } } diff --git a/src/Common/AlertClassEnum.cs b/src/Common/AlertClassEnum.cs new file mode 100644 index 0000000..59576e5 --- /dev/null +++ b/src/Common/AlertClassEnum.cs @@ -0,0 +1,11 @@ +namespace Tailgrab.Common +{ + public enum AlertClassEnum + { + Avatar = 0, + Group = 1, + Profile = 2, + Print = 3, + EmojiSticker = 4 + } +} \ No newline at end of file diff --git a/src/Common/AlertTypeEnum.cs b/src/Common/AlertTypeEnum.cs new file mode 100644 index 0000000..736b7bc --- /dev/null +++ b/src/Common/AlertTypeEnum.cs @@ -0,0 +1,10 @@ +namespace Tailgrab.Common +{ + public enum AlertTypeEnum + { + None = 0, + Watch = 1, + Nuisance = 2, + Crasher = 3 + } +} diff --git a/src/Common/Checksum.cs b/src/Common/Checksum.cs index a349e45..0fe250a 100644 --- a/src/Common/Checksum.cs +++ b/src/Common/Checksum.cs @@ -10,7 +10,7 @@ public static string CreateMD5(string input) byte[] inputBytes = System.Text.Encoding.ASCII.GetBytes(input); byte[] hashBytes = md5.ComputeHash(inputBytes); - return Convert.ToHexString(hashBytes); + return Convert.ToHexString(hashBytes); } } public static string CreateMD5(byte[] imageBytes) diff --git a/src/Common/Common.cs b/src/Common/CommonConst.cs similarity index 59% rename from src/Common/Common.cs rename to src/Common/CommonConst.cs index 77b2438..fdb5325 100644 --- a/src/Common/Common.cs +++ b/src/Common/CommonConst.cs @@ -1,7 +1,12 @@ -namespace Tailgrab.Common +using System.IO; + +namespace Tailgrab.Common { - public static class Common + public static class CommonConst { + + public static string APPLICATION_LOCAL_DATA_PATH = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Tailgrab" ); + public const string APPLICATION_LOCAL_DATABASE = "tailgrab.db"; public const string ApplicationName = "Tailgrab"; public const string CompanyName = "DeviousFox"; public const string ConfigRegistryPath = "Software\\DeviousFox\\Tailgrab\\Config"; @@ -17,7 +22,7 @@ public static class Common public const string Registry_Ollama_API_Prompt = "OLLAMA_API_PROMPT"; public const string Registry_Ollama_API_Image_Prompt = "OLLAMA_API_PROMPT_IMAGE"; public const string Registry_Ollama_API_Model = "OLLAMA_API_Model"; - public const string Default_Ollama_API_Prompt = "From the following block of text, classify the contents into a single class from the following classes;\r\n'OK' - Where as all text content can be considered PG13;\r\n'Explicit Sexual' - Where as any of the text contained describes sexual acts or intent. Flagged words Bussy, Fagot, Dih;\r\n'Harassment & Bullying' - Where the text is describing acts of trolling or bullying users on Religion, Sexual Orientation or Race. Flagged words of base nigg* and variations of that spelling to hide racism.\r\n'Self Harm' - Any part of the text where it explicitly describes destructive behaviours.\r\nIf there is not enough information to determine the class, use a default of OK. When replying, return a single line for the Classification and a carriage return, then place the reasoning on subsequent lines, translate any foreign language to English: \n"; + public const string Default_Ollama_API_Prompt = "From the following block of text, classify the contents into a single class from the following classes; 'OK' - Where as all text content can be considered PG13; 'Explicit Sexual' - Where as any of the text contained describes sexual acts or intent. Flagged words Bussy, Fagot, Dih; 18+ and 21+ age requirements do not indicate sexual intent. 'Harassment & Bullying' - Where the text is describing acts of trolling or bullying users on Religion, Sexual Orientation or Race. Flagged words of base nigg* and variations of that spelling to hide racism. 'Self Harm' - Any part of the text where it explicitly describes destructive behaviours. If there is not enough information to determine the class, use a default of OK. When replying, return a single line for the Classification and a carriage return, then place the reasoning on subsequent lines, translate any foreign language to English:\n"; public const string Default_Ollama_API_Image_Prompt = "From the attached image, reply with a single classification of, 'OK', 'Sexual Content', 'Gore' or 'Racism'. In the following line, give the reasoning for the classifcation."; public const string Default_Ollama_API_Endpoint = "https://ollama.com"; public const string Default_Ollama_API_Model = "gemma3:27b"; @@ -34,5 +39,12 @@ public static class Common // Avatar Gist related registry keys public const string Registry_Avatar_Checksum = "GIST_AVATAR_LIST_CHECKSUM"; public const string Registry_Avatar_Gist = "GIST_AVATAR_LIST_URL"; + + public const string Avatar_Alert_Key = "Avatar"; + public const string Group_Alert_Key = "Group"; + public const string Profile_Alert_Key = "Profile"; + public const string Sound_Alert_Key = "Sound"; + public const string Color_Alert_Key = "Color"; + } } diff --git a/src/Common/ConfigStore.cs b/src/Common/ConfigStore.cs index 9aa263c..3ca1154 100644 --- a/src/Common/ConfigStore.cs +++ b/src/Common/ConfigStore.cs @@ -18,7 +18,7 @@ public static void SaveSecret(string name, string value) var protectedBytes = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser); var base64 = Convert.ToBase64String(protectedBytes); - using (var key = Registry.CurrentUser.CreateSubKey(Common.ConfigRegistryPath)) + using (var key = Registry.CurrentUser.CreateSubKey(CommonConst.ConfigRegistryPath)) { key.SetValue(name, base64, RegistryValueKind.String); } @@ -28,7 +28,7 @@ public static void SaveSecret(string name, string value) { if (name == null) throw new ArgumentNullException(nameof(name)); - using (var key = Registry.CurrentUser.OpenSubKey(Common.ConfigRegistryPath)) + using (var key = Registry.CurrentUser.OpenSubKey(CommonConst.ConfigRegistryPath)) { if (key == null) return null; var base64 = key.GetValue(name) as string; @@ -48,18 +48,23 @@ public static void SaveSecret(string name, string value) public static void DeleteSecret(string name) { - using (var key = Registry.CurrentUser.OpenSubKey(Common.ConfigRegistryPath, writable: true)) + using (var key = Registry.CurrentUser.OpenSubKey(CommonConst.ConfigRegistryPath, writable: true)) { if (key == null) return; key.DeleteValue(name, throwOnMissingValue: false); } } - public static string? GetStoredUri(string keyName) + public static string? GetStoredKeyString(string keyName) + { + return GetStoredKeyString(CommonConst.ConfigRegistryPath, keyName); + } + + public static string? GetStoredKeyString(string keyPath, string keyName) { try { - using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.ConfigRegistryPath)) + using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(keyPath)) { if (key == null) { @@ -84,11 +89,16 @@ public static void DeleteSecret(string name) } } - public static void PutStoredUri(string keyName, string keyValue) + public static void PutStoredKeyString(string keyName, string keyValue) + { + PutStoredKeyString(CommonConst.ConfigRegistryPath, keyName, keyValue); + } + + public static void PutStoredKeyString(string keyPath, string keyName, string keyValue) { try { - using (RegistryKey key = Registry.CurrentUser.CreateSubKey(Common.ConfigRegistryPath)) + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(keyPath)) { key.SetValue(keyName, keyValue, RegistryValueKind.String); } @@ -98,5 +108,15 @@ public static void PutStoredUri(string keyName, string keyValue) logger.Error(ex, $"Failed to save value to registry. {keyName}"); } } + + public static void RemoveStoredKeyString(string keyPath, string keyName) + { + using (var key = Registry.CurrentUser.OpenSubKey(keyPath, writable: true)) + { + if (key == null) return; + key.DeleteValue(keyName, throwOnMissingValue: false); + } + } + } } diff --git a/src/Common/SoundManager.cs b/src/Common/SoundManager.cs index 248523f..c7de1d1 100644 --- a/src/Common/SoundManager.cs +++ b/src/Common/SoundManager.cs @@ -16,7 +16,7 @@ namespace Tailgrab.Common public static class SoundManager { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private static readonly string[] allSystemSounds = { "Asterisk", "Beep", "Exclamation", "Warning", "Hand", "Error", "Question" }; + private static readonly string[] allSystemSounds = { "*NONE", "Asterisk", "Beep", "Exclamation", "Warning", "Hand", "Error", "Question" }; /// /// Enumerate available sound base filenames (without extension) from the ./sounds directory. @@ -26,34 +26,40 @@ public static List GetAvailableSounds() { try { - var baseDir = AppContext.BaseDirectory ?? Directory.GetCurrentDirectory(); - var soundsDir = Path.Combine(baseDir, "sounds"); - if (!Directory.Exists(soundsDir)) + var soundsDir = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "sounds"); + if (Directory.Exists(soundsDir)) { - return new List(); - } - var exts = new[] { ".wav", ".mp3", ".ogg" }; - var files = Directory.EnumerateFiles(soundsDir) - .Where(f => exts.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase)) - .Select(f => Path.GetFileNameWithoutExtension(f)) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) - .ToList(); - - files = allSystemSounds - .Concat(files) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - return files; + var exts = new[] { ".wav", ".mp3", ".ogg" }; + var files = Directory.EnumerateFiles(soundsDir) + .Where(f => exts.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase)) + .Select(f => Path.GetFileNameWithoutExtension(f)) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + + files = allSystemSounds + .Concat(files) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return files; + } } catch (Exception ex) { Logger.Warn(ex, "Failed to enumerate sounds directory"); - return new List(); } + + return new List(); + } + + public static void PlayAlertSound(string alertKey, AlertTypeEnum alertType) + { + string key = CommonConst.ConfigRegistryPath + "\\" + alertKey + "\\" + alertType.ToString(); + string soundSetting = ConfigStore.GetStoredKeyString(key, CommonConst.Sound_Alert_Key) ?? "Hand"; + PlaySound(soundSetting); } /// @@ -93,8 +99,7 @@ public static void PlaySound(string? name) // Treat as filename under ./sounds try { - var baseDir = AppContext.BaseDirectory ?? Directory.GetCurrentDirectory(); - var soundsDir = Path.Combine(baseDir, "sounds"); + var soundsDir = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "sounds"); string candidate = name; // If an absolute or relative path was passed, respect it diff --git a/src/LineHandlers/AvatarChangeHandler.cs b/src/LineHandlers/AvatarChangeHandler.cs index 44366d0..51b6f8b 100644 --- a/src/LineHandlers/AvatarChangeHandler.cs +++ b/src/LineHandlers/AvatarChangeHandler.cs @@ -2,7 +2,6 @@ namespace Tailgrab.LineHandler; using System.Text.RegularExpressions; using Tailgrab.Common; -using Tailgrab.PlayerManagement; public class AvatarChangeHandler : AbstractLineHandler { diff --git a/src/LineHandlers/PenNetworkIdHandler.cs b/src/LineHandlers/PenNetworkIdHandler.cs index d0314ec..827a0f8 100644 --- a/src/LineHandlers/PenNetworkIdHandler.cs +++ b/src/LineHandlers/PenNetworkIdHandler.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.RegularExpressions; using Tailgrab.PlayerManagement; +using Tailgrab.Common; namespace Tailgrab.LineHandler; @@ -22,7 +23,8 @@ public PenNetworkHandler(string matchPattern, ServiceRegistry serviceRegistry) : logger.Info($"** Pen Network Id Handler: Regular Expression: {Pattern}"); - using (FileStream fs = new FileStream("./pen-network-id.csv", FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + string penNetworkFilePath = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "pen-network-id.csv"); + using (FileStream fs = new FileStream(penNetworkFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (StreamReader sr = new StreamReader(fs, Encoding.UTF8)) { Console.WriteLine($"Loading Pen Network ID mappings..."); diff --git a/src/LineHandlers/QuitHandler.cs b/src/LineHandlers/QuitHandler.cs index 35c6bda..7be5eca 100644 --- a/src/LineHandlers/QuitHandler.cs +++ b/src/LineHandlers/QuitHandler.cs @@ -26,17 +26,13 @@ public override bool HandleLine(string line) string totalTime = m.Groups[VRC_TOTALSEC].Value; // Create a TimeSpan object from the total number of seconds - //TimeSpan time = TimeSpan.FromSeconds(int.Parse(totalTime)); - - // Access the individual components (Hours, Minutes, Seconds) - //int hours = time.Hours; - //int minutes = time.Minutes; - //int seconds = time.Seconds; + TimeSpan time = TimeSpan.FromSeconds(Double.Parse(totalTime)); if (LogOutput) { //string formattedTime = string.Format("{0:D2}:{1:D2}:{2:D2}", time.Hours, time.Minutes, time.Seconds); - logger.Info($"{COLOR_PREFIX}Application Stop : {totalTime} seconds{COLOR_RESET.GetAnsiEscape()}"); + //logger.Info($"{COLOR_PREFIX}Application Stop : {totalTime} seconds{COLOR_RESET.GetAnsiEscape()}"); + logger.Info($"{COLOR_PREFIX}Application Stop : {time} {COLOR_RESET.GetAnsiEscape()}"); } _serviceRegistry.GetPlayerManager().ClearAllPlayers(this); diff --git a/src/LineHandlers/VTKHandler.cs b/src/LineHandlers/VTKHandler.cs index e308f2b..6f84b48 100644 --- a/src/LineHandlers/VTKHandler.cs +++ b/src/LineHandlers/VTKHandler.cs @@ -30,8 +30,12 @@ public override bool HandleLine(string line) logger.Info($"{COLOR_PREFIX}VTK : {userName}{COLOR_RESET.GetAnsiEscape()}"); } - _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, "Vote kick initiated against player."); - + Player? player = _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, "Vote kick initiated against player."); + if (player != null) + { + player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Nuisance, $"VTK"); + } + ExecuteActions(); return true; } diff --git a/src/LineHandlers/WarnKickHandler.cs b/src/LineHandlers/WarnKickHandler.cs index 0a92c99..e3280bd 100644 --- a/src/LineHandlers/WarnKickHandler.cs +++ b/src/LineHandlers/WarnKickHandler.cs @@ -31,8 +31,12 @@ public override bool HandleLine(string line) { logger.Info($"{COLOR_PREFIX}User Moderation : {userName} to {action}{COLOR_RESET.GetAnsiEscape()}"); } - - _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, $"User has been {action}."); + + Player? player = _serviceRegistry.GetPlayerManager().AddPlayerEventByDisplayName(userName, PlayerEvent.EventType.Moderation, $"User has been {action}."); + if (player != null) + { + player.AddAlertMessage(AlertClassEnum.Profile, AlertTypeEnum.Nuisance, action); + } ExecuteActions(); return true; diff --git a/src/Models/AvatarInfo.cs b/src/Models/AvatarInfo.cs index c077ea5..2e5c342 100644 --- a/src/Models/AvatarInfo.cs +++ b/src/Models/AvatarInfo.cs @@ -2,6 +2,7 @@ #nullable disable using System; using System.Collections.Generic; +using Tailgrab.Common; namespace Tailgrab.Models; @@ -17,10 +18,12 @@ public partial class AvatarInfo public DateTime? UpdatedAt { get; set; } - public bool IsBos { get; set; } + public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.None; public string ImageUrl { get; set; } + public string UserName { get; set; } + public AvatarInfo() { CreatedAt = DateTime.UtcNow; @@ -28,6 +31,6 @@ public AvatarInfo() public override string ToString() { - return $"GroupId: {AvatarId}, UserId: {UserId}, GroupName: {AvatarName}, CreatedAt: {CreatedAt}, UpdatedAt: {UpdatedAt}, IsBOS: {IsBos}"; + return $"AvatarInfo: AvatarId={AvatarId}, UserId={UserId}, AvatarName={AvatarName}, CreatedAt={CreatedAt}, UpdatedAt={UpdatedAt}, AlertType={AlertType}, ImageUrl={ImageUrl}, UserName={UserName}"; } } \ No newline at end of file diff --git a/src/Models/GroupInfo.cs b/src/Models/GroupInfo.cs index 28b6ece..bf4086c 100644 --- a/src/Models/GroupInfo.cs +++ b/src/Models/GroupInfo.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using System; using System.Collections.Generic; +using Tailgrab.Common; using VRChat.API.Model; namespace Tailgrab.Models; @@ -13,7 +14,7 @@ public partial class GroupInfo public string GroupName{ get; set; } - public bool IsBos{ get; set; } + public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.None; public DateTime CreatedAt { get; set; } @@ -26,6 +27,6 @@ public GroupInfo() public override string ToString() { - return $"GroupInfo - GroupId: {GroupId}, GroupName: {GroupName}, IsBOS: {IsBos}, CreatedAt: {CreatedAt}, UpdatedAt: {UpdatedAt}"; + return $"GroupId: {GroupId}, GroupName: {GroupName}, AlertType: {AlertType}, CreatedAt: {CreatedAt}, UpdatedAt: {UpdatedAt}"; } } diff --git a/src/Models/ImageEvaluation.cs b/src/Models/ImageEvaluation.cs index eb67bff..d8b37bc 100644 --- a/src/Models/ImageEvaluation.cs +++ b/src/Models/ImageEvaluation.cs @@ -13,6 +13,7 @@ public partial class ImageEvaluation public string Md5checksum { get; set; } public byte[] Evaluation { get; set; } public DateTime LastDateTime { get; set; } + public bool IsIgnored { get; set; } = false; public ImageEvaluation() { @@ -21,6 +22,6 @@ public ImageEvaluation() public override string ToString() { - return $"InventoryId: {InventoryId}, UserId: {UserId}, Md5checksum: {Md5checksum}, Evaluation: {BitConverter.ToString(Evaluation)}, LastDateTime: {LastDateTime}"; + return $"ImageEvaluation: InventoryId={InventoryId}, UserId={UserId}, Md5checksum={Md5checksum}, LastDateTime={LastDateTime}, IsIgnored={IsIgnored}"; } } \ No newline at end of file diff --git a/src/Models/ProfileEvaluation.cs b/src/Models/ProfileEvaluation.cs index 697f6bd..9909e12 100644 --- a/src/Models/ProfileEvaluation.cs +++ b/src/Models/ProfileEvaluation.cs @@ -17,6 +17,8 @@ public partial class ProfileEvaluation public DateTime LastDateTime { get; set; } + public bool IsIgnored { get; set; } = false; + public ProfileEvaluation() { LastDateTime = DateTime.UtcNow; @@ -24,6 +26,6 @@ public ProfileEvaluation() public override string ToString() { - return $"ProfileEvaluation: {Md5checksum}, ProfileText: {ProfileText}, Evaluation: {Evaluation}, LastDateTime: {LastDateTime}"; + return $"ProfileEvaluation: Md5checksum={Md5checksum}, LastDateTime={LastDateTime}, IsIgnored={IsIgnored}"; } } \ No newline at end of file diff --git a/src/Models/TailgrabDBContext.cs b/src/Models/TailgrabDBContext.cs index 9c70505..31ffe23 100644 --- a/src/Models/TailgrabDBContext.cs +++ b/src/Models/TailgrabDBContext.cs @@ -1,10 +1,17 @@ // This file has been auto generated by EF Core Power Tools. -#nullable disable +#nullable enable +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.Options; +using NLog; using System; using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Windows.Media.Animation; +using Tailgrab.Common; namespace Tailgrab.Models; @@ -13,8 +20,12 @@ public class TailgrabContextFactory : IDesignTimeDbContextFactory(); - optionsBuilder.UseSqlite("Data Source=./Resources/tailgrab-dev.sqlite"); + optionsBuilder.UseSqlite($"Data Source={dbPath}"); return new TailgrabDBContext(optionsBuilder.Options); } @@ -22,6 +33,9 @@ public TailgrabDBContext CreateDbContext(string[] args) public partial class TailgrabDBContext : DbContext { + + Logger logger = LogManager.GetCurrentClassLogger(); + public TailgrabDBContext(DbContextOptions options) : base(options) { @@ -29,7 +43,15 @@ public TailgrabDBContext(DbContextOptions options) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - optionsBuilder.UseSqlite("Data Source=./data/avatars.sqlite"); + // Define directory: %LOCALAPPDATA%\YourAppName + string dbFolder = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "data"); + string dbPath = Path.Combine(dbFolder, CommonConst.APPLICATION_LOCAL_DATABASE); + + // Ensure directory exists + Directory.CreateDirectory(dbFolder); + + // Configure SQLite + optionsBuilder.UseSqlite($"Data Source={dbPath}"); } public virtual DbSet AvatarInfos { get; set; } @@ -49,8 +71,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.AvatarId); entity.ToTable("AvatarInfo"); - - entity.Property(e => e.IsBos).HasColumnName("IsBOS"); }); modelBuilder.Entity(entity => @@ -59,7 +79,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("GroupInfo"); - entity.Property(e => e.IsBos).HasColumnName("IsBOS"); entity.Property(e => e.CreatedAt).HasColumnName("createDate"); entity.Property(e => e.UpdatedAt).HasColumnName("updateDate"); }); @@ -71,6 +90,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("ProfileEvaluation"); entity.Property(e => e.Md5checksum).HasColumnName("MD5Checksum"); + entity.Property(e => e.IsIgnored).HasColumnName("isIgnored"); }); modelBuilder.Entity(entity => @@ -80,8 +100,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("UserInfo"); entity.Property(e => e.CreatedAt).IsRequired(); - entity.Property(e => e.ElapsedMinutes).HasColumnName("elapsedHours"); - entity.Property(e => e.IsBos).HasColumnName("IsBOS"); }); modelBuilder.Entity(entity => @@ -91,10 +109,506 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("ImageEvaluation"); entity.Property(e => e.Md5checksum).HasColumnName("MD5Checksum"); + entity.Property(e => e.IsIgnored).HasColumnName("isIgnored"); }); OnModelCreatingPartial(modelBuilder); } partial void OnModelCreatingPartial(ModelBuilder modelBuilder); + + public void UpgradeDatabase() + { + ExecuteSqlTransaction( + "ALTER TABLE AvatarInfo ADD COLUMN alertType INTEGER NOT NULL DEFAULT 0", + "UPDATE AvatarInfo SET alertType = 1 WHERE IsBOS = 1", + "ALTER TABLE GroupInfo ADD COLUMN alertType INTEGER NOT NULL DEFAULT 0", + "UPDATE GroupInfo SET alertType = 1 WHERE IsBOS = 1" + ); + } + + private void ExecuteSql(string sql) + { + Database.ExecuteSqlRaw(sql); + } + + private void ExecuteSqlTransaction(params string[] sqlStatements) + { + using var transaction = Database.BeginTransaction(); + try + { + foreach (var sql in sqlStatements) + { + Database.ExecuteSqlRaw(sql); + } + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + private async Task ExecuteSqlAsync(string sql) + { + await Database.ExecuteSqlRawAsync(sql); + } + + private async Task ExecuteSqlTransactionAsync(params string[] sqlStatements) + { + using var transaction = await Database.BeginTransactionAsync(); + try + { + foreach (var sql in sqlStatements) + { + await Database.ExecuteSqlRawAsync(sql); + } + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + public void CreateDatabaseBackup() + { + try + { + var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); + var databasePath = Path.Combine(dataDir, "avatars.sqlite"); + + if (!File.Exists(databasePath)) + { + logger.Warn($"Database file not found at '{databasePath}'. Nothing to backup."); + return; + } + + // Create backup directory with timestamp + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var backupDirName = $"backup_{timestamp}"; + var backupDir = Path.Combine(dataDir, backupDirName); + Directory.CreateDirectory(backupDir); + + logger.Info($"Creating database backup in: '{backupDirName}'"); + + // Export each table to JSON + int totalRecords = 0; + + // Export AvatarInfo table + logger.Info("Exporting AvatarInfo table..."); + var avatars = AvatarInfos.ToList(); + var avatarsJson = JsonSerializer.Serialize(avatars, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(backupDir, "AvatarInfo.json"), avatarsJson); + logger.Info($" Exported {avatars.Count} avatar records"); + totalRecords += avatars.Count; + + // Export GroupInfo table + logger.Info("Exporting GroupInfo table..."); + var groups = GroupInfos.ToList(); + var groupsJson = JsonSerializer.Serialize(groups, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(backupDir, "GroupInfo.json"), groupsJson); + logger.Info($" Exported {groups.Count} group records"); + totalRecords += groups.Count; + + // Export UserInfo table + logger.Info("Exporting UserInfo table..."); + var users = UserInfos.ToList(); + var usersJson = JsonSerializer.Serialize(users, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(backupDir, "UserInfo.json"), usersJson); + logger.Info($" Exported {users.Count} user records"); + totalRecords += users.Count; + + // Export ProfileEvaluation table + logger.Info("Exporting ProfileEvaluation table..."); + var profiles = ProfileEvaluations.ToList(); + var profilesJson = JsonSerializer.Serialize(profiles, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(backupDir, "ProfileEvaluation.json"), profilesJson); + logger.Info($" Exported {profiles.Count} profile evaluation records"); + totalRecords += profiles.Count; + + // Export ImageEvaluation table + logger.Info("Exporting ImageEvaluation table..."); + var images = ImageEvaluations.ToList(); + var imagesJson = JsonSerializer.Serialize(images, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(backupDir, "ImageEvaluation.json"), imagesJson); + logger.Info($" Exported {images.Count} image evaluation records"); + totalRecords += images.Count; + + // Create backup metadata file + var metadata = new + { + BackupTimestamp = timestamp, + BackupDate = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + ApplicationVersion = BuildInfo.GetInformationalVersion(), + DatabasePath = databasePath, + TotalRecords = totalRecords, + Tables = new[] + { + new { TableName = "AvatarInfo", RecordCount = avatars.Count }, + new { TableName = "GroupInfo", RecordCount = groups.Count }, + new { TableName = "UserInfo", RecordCount = users.Count }, + new { TableName = "ProfileEvaluation", RecordCount = profiles.Count }, + new { TableName = "ImageEvaluation", RecordCount = images.Count } + } + }; + var metadataJson = JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(backupDir, "_backup_metadata.json"), metadataJson); + + // Calculate total backup size + var backupDirInfo = new DirectoryInfo(backupDir); + var totalSize = backupDirInfo.GetFiles().Sum(f => f.Length); + + logger.Info($"Database backup completed successfully:"); + logger.Info($" Location: '{backupDir}'"); + logger.Info($" Total records: {totalRecords}"); + logger.Info($" Backup size: {totalSize / 1024.0:F2} KB"); + + // Clean up old backups (keep only last 10) + CleanupOldBackups(dataDir, 10); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to create database backup"); + } + } + + /// + /// Clean up old backup directories, keeping only the specified number of most recent backups. + /// + private void CleanupOldBackups(string dataDir, int keepCount) + { + try + { + var backupDirs = Directory.GetDirectories(dataDir, "backup_*") + .Select(d => new DirectoryInfo(d)) + .OrderByDescending(d => d.CreationTime) + .ToList(); + + if (backupDirs.Count > keepCount) + { + var dirsToDelete = backupDirs.Skip(keepCount); + foreach (var dir in dirsToDelete) + { + logger.Info($"Deleting old backup directory: '{dir.Name}'"); + dir.Delete(recursive: true); + } + } + } + catch (Exception ex) + { + logger.Warn(ex, "Failed to clean up old backup directories"); + } + } + + public MigrationStatus MigrateOldVersion(string dataDir) + { + MigrationStatus status = new MigrationStatus(); + List migrationTables = new List() + { + "AvatarInfo", + "GroupInfo", + "UserInfo", + "ProfileEvaluation", + "ImageEvaluation" + }; + + foreach (var table in migrationTables) + { + int count = GetTableCount(dataDir, table); + status.Messages.Add($"Table '{table}' has {count} records."); + logger.Info($"Table '{table}' has {count} records."); + + int migratedCount = MigrateTable(dataDir, table); + status.Messages.Add($"Migrated {migratedCount} records from '{table}' to new database."); + logger.Info($"Migrated {migratedCount} records from '{table}' to new database."); + } + + return status; + } + + public int MigrateTable(string dataDir, string tableName) + { + switch (tableName) + { + case "GroupInfo": + return MigrateGroupInfo(dataDir); + case "UserInfo": + return MigrateUserInfo(dataDir); + case "ProfileEvaluation": + return MigrateProfileEvaluation(dataDir); + case "ImageEvaluation": + return MigrateImageEvaluation(dataDir); + case "AvatarInfo": + return MigrateAvatarInfo(dataDir); + default: + logger.Warn($"No migration logic defined for table '{tableName}'. Skipping."); + return 0; + } + } + + private Int32 GetTableCount(string dataDir, string tableName) + { + string connectionString = $"Data Source={Path.Combine(AppContext.BaseDirectory, "data", "avatars.sqlite")}"; + string query = $"SELECT Count() FROM {tableName}"; + try + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = new SqliteCommand(query, connection); + + return Convert.ToInt32(command.ExecuteScalar()); + } + catch (Exception ex) + { + logger.Error(ex, $"Error reading {tableName} data for record count"); + return 0; + } + + } + + private Int32 MigrateAvatarInfo(string dataDir) + { + int count = 0; + string connectionString = $"Data Source={Path.Combine(AppContext.BaseDirectory, "data", "avatars.sqlite")}"; + string query = $"SELECT AvatarId, UserId, AvatarName, CreatedAt, UpdatedAt, IsBOS, ImageUrl FROM AvatarInfo"; + try + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = new SqliteCommand(query, connection); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + string avatarId = reader.GetString(0); + string userId = reader.GetString(1); + string avatarName = reader.GetString(2); + string createdAt = reader.GetString(3); + string updatedAt = reader.GetString(4); + bool isBOS = reader.GetBoolean(5); + string imageUrl = reader.GetString(6); + + AvatarInfo? existing = AvatarInfos.Find(avatarId); + if (existing == null && !string.IsNullOrEmpty(userId)) + { + AvatarInfo avatarInfo = new AvatarInfo(); + avatarInfo.AvatarId = avatarId; + avatarInfo.UserId = userId; + avatarInfo.AvatarName = avatarName; + avatarInfo.CreatedAt = DateTime.Parse(createdAt); + avatarInfo.UpdatedAt = DateTime.Parse(updatedAt); + avatarInfo.AlertType = isBOS ? AlertTypeEnum.Watch : AlertTypeEnum.None; + avatarInfo.ImageUrl = imageUrl; + + this.Add(avatarInfo); + count++; + } + } + this.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.Error(ex, "Error reading AvatarInfo data for migration"); + return count; + } + + return count; + } + + private Int32 MigrateGroupInfo(string dataDir) + { + int count = 0; + + string connectionString = $"Data Source={Path.Combine(AppContext.BaseDirectory, "data", "avatars.sqlite")}"; + string query = $"SELECT groupId, groupName, createDate, updateDate, IsBOS FROM GroupInfo"; + try + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = new SqliteCommand(query, connection); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + string groupId = reader.GetString(0); + string groupName = reader.GetString(1); + string createdAt = reader.GetString(2); + string updatedAt = reader.GetString(3); + bool isBOS = reader.GetBoolean(4); + + GroupInfo? existing = GroupInfos.Find(groupId); + if (existing == null) + { + GroupInfo groupInfo = new GroupInfo(); + groupInfo.GroupId = groupId; + groupInfo.GroupName = groupName; + groupInfo.CreatedAt = DateTime.Parse(createdAt); + groupInfo.UpdatedAt = DateTime.Parse(updatedAt); + groupInfo.AlertType = isBOS ? AlertTypeEnum.Watch : AlertTypeEnum.None; + + this.Add(groupInfo); + count++; + } + } + this.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.Error(ex, "Error reading GroupInfo data for migration"); + return count; + } + + return count; + } + + private Int32 MigrateUserInfo(string dataDir) + { + int count = 0; + string connectionString = $"Data Source={Path.Combine(AppContext.BaseDirectory, "data", "avatars.sqlite")}"; + string query = $"SELECT UserId, DisplayName, elapsedHours, CreatedAt, UpdatedAt, IsBOS FROM UserInfo"; + try + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = new SqliteCommand(query, connection); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + string userId = reader.GetString(0); + string displayName = reader.GetString(1); + float elapsedHours = reader.GetFloat(2); + string createdAt = reader.GetString(3); + string updatedAt = reader.GetString(4); + bool isBOS = reader.GetBoolean(5); + + UserInfo? existing = UserInfos.Find(userId); + if (existing == null) + { + UserInfo userInfo = new UserInfo(); + userInfo.UserId = userId; + userInfo.DisplayName = displayName; + userInfo.ElapsedMinutes = elapsedHours; + userInfo.CreatedAt = DateTime.Parse(createdAt); + userInfo.UpdatedAt = DateTime.Parse(updatedAt); + //userInfo.AlertType = isBOS ? AlertTypeEnum.Watch : AlertTypeEnum.None; + + this.Add(userInfo); + count++; + } + } + this.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.Error(ex, "Error reading UserInfo data for migration"); + return count; + } + + return count; + } + + private Int32 MigrateProfileEvaluation(string dataDir) + { + int count = 0; + string connectionString = $"Data Source={Path.Combine(AppContext.BaseDirectory, "data", "avatars.sqlite")}"; + string query = $"SELECT MD5Checksum, ProfileText, Evaluation, LastDateTime FROM ProfileEvaluation"; + try + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = new SqliteCommand(query, connection); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + string md5Checksum = reader.GetString(0); + string profileText = reader.GetString(1); + string evaluation = reader.GetString(2); + string lastDateTime = reader.GetString(3); + + ProfileEvaluation? existing = ProfileEvaluations.Find(md5Checksum); + if (existing == null) + { + ProfileEvaluation profileEval = new ProfileEvaluation(); + profileEval.Md5checksum = md5Checksum; + profileEval.ProfileText = System.Text.Encoding.UTF8.GetBytes(profileText); + profileEval.Evaluation = System.Text.Encoding.UTF8.GetBytes(evaluation); + profileEval.LastDateTime = DateTime.Parse(lastDateTime); + //profileEval.AlertType = isBOS ? AlertTypeEnum.Watch : AlertTypeEnum.None; + + this.Add(profileEval); + count++; + } + } + this.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.Error(ex, "Error reading UserInfo data for migration"); + return count; + } + + return count; + } + + private Int32 MigrateImageEvaluation(string dataDir) + { + int count = 0; + string connectionString = $"Data Source={Path.Combine(AppContext.BaseDirectory, "data", "avatars.sqlite")}"; + string query = $"SELECT InventoryId, MD5Checksum, Evaluation, LastDateTime, UserId FROM ImageEvaluation"; + try + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = new SqliteCommand(query, connection); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + string inventoryId = reader.GetString(0); + string md5Checksum = reader.GetString(1); + string evaluation = reader.GetString(2); + string lastDateTime = reader.GetString(3); + string userId = reader.GetString(4); + + ImageEvaluation? existing = ImageEvaluations.Find(inventoryId); + if (existing == null) + { + ImageEvaluation imageEval = new ImageEvaluation(); + imageEval.InventoryId = inventoryId; + imageEval.Md5checksum = md5Checksum; + imageEval.Evaluation = System.Text.Encoding.UTF8.GetBytes(evaluation); + imageEval.LastDateTime = DateTime.Parse(lastDateTime); + imageEval.UserId = userId; + //imageEval.AlertType = isBOS ? AlertTypeEnum.Watch : AlertTypeEnum.None; + + this.Add(imageEval); + count++; + } + } + this.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.Error(ex, "Error reading UserInfo data for migration"); + return count; + } + + return count; + } +} + + +public class MigrationStatus +{ + public List Messages { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Models/UserInfo.cs b/src/Models/UserInfo.cs index aa533ce..40fe98d 100644 --- a/src/Models/UserInfo.cs +++ b/src/Models/UserInfo.cs @@ -2,7 +2,6 @@ #nullable disable using System; using System.Collections.Generic; -using VRChat.API.Model; namespace Tailgrab.Models; @@ -18,7 +17,10 @@ public partial class UserInfo public DateTime UpdatedAt { get; set; } - public int IsBos { get; set; } + public DateOnly DateJoined { get; set; } + + public string LastProfileChecksum { get; set; } + public UserInfo() { CreatedAt = DateTime.UtcNow; @@ -26,6 +28,6 @@ public UserInfo() public override string ToString() { - return $"UserInfo: {UserId}, DisplayName: {DisplayName}, ElapsedMinutes: {ElapsedMinutes}, CreatedAt: {CreatedAt}, UpdatedAt: {UpdatedAt}"; + return $"UserInfo: UserId={UserId}, DisplayName={DisplayName}, ElapsedMinutes={ElapsedMinutes}, CreatedAt={CreatedAt}, UpdatedAt={UpdatedAt}, DateJoined={DateJoined}, LastProfileChecksum={LastProfileChecksum}"; } } \ No newline at end of file diff --git a/src/NLog.config b/src/NLog.config index b15b72e..63b13fd 100644 --- a/src/NLog.config +++ b/src/NLog.config @@ -5,13 +5,15 @@ throwConfigExceptions="true"> - - + - + \ No newline at end of file diff --git a/src/PlayerManagement/AvatarCollection.cs b/src/PlayerManagement/AvatarCollection.cs index a471ece..60dcdac 100644 --- a/src/PlayerManagement/AvatarCollection.cs +++ b/src/PlayerManagement/AvatarCollection.cs @@ -1,6 +1,7 @@ +using Microsoft.EntityFrameworkCore; using System.Collections.Specialized; using System.ComponentModel; -using Microsoft.EntityFrameworkCore; +using Tailgrab.Common; namespace Tailgrab.PlayerManagement { @@ -43,7 +44,7 @@ private void EnsureCount() { var db = _services.GetDBContext(); var query = db.AvatarInfos.AsQueryable(); - + if (!string.IsNullOrWhiteSpace(_filterText)) { if (_filterText.StartsWith("avtr_", StringComparison.OrdinalIgnoreCase)) @@ -55,7 +56,7 @@ private void EnsureCount() query = query.Where(a => EF.Functions.Like(a.AvatarName, $"%{_filterText}%")); } } - + _count = query.Count(); } catch @@ -78,13 +79,13 @@ private void EnsureCount() var db = _services.GetDBContext(); var skip = page * _pageSize; var query = db.AvatarInfos.AsQueryable(); - + if (!string.IsNullOrWhiteSpace(_filterText)) { var filterLower = _filterText.ToLower(); query = query.Where(a => a.AvatarName.ToLower().Contains(filterLower)); } - + var items = query.OrderBy(a => a.AvatarName).Skip(skip).Take(_pageSize).ToList(); list = items.Select(a => new AvatarInfoViewModel(a)).ToList(); _pages[page] = list; @@ -152,39 +153,35 @@ public System.Collections.IEnumerator GetEnumerator() public class AvatarInfoViewModel : INotifyPropertyChanged { + private AlertTypeEnum _alertType; + public string AvatarId { get; set; } public string AvatarName { get; set; } - private bool _isBos; - public bool IsBos + public string UserName { get; set; } + public DateTime? UpdatedAt { get; set; } + + public AlertTypeEnum AlertType { - get => _isBos; + get => _alertType; set { - if (_isBos != value) + if (_alertType != value) { - _isBos = value; - IsBosText = BoolToYesNo(_isBos); - OnPropertyChanged(nameof(IsBos)); - OnPropertyChanged(nameof(IsBosText)); + _alertType = value; + OnPropertyChanged(nameof(AlertType)); } } } - public string IsBosText { get; set; } - public DateTime? UpdatedAt { get; set; } - public AvatarInfoViewModel(Tailgrab.Models.AvatarInfo a) { AvatarId = a.AvatarId; AvatarName = a.AvatarName; - IsBos = a.IsBos; UpdatedAt = a.UpdatedAt; - IsBosText = BoolToYesNo(IsBos); + AlertType = a.AlertType; + UserName = a.UserName ?? "Unknown"; } - // Convert boolean to YES/NO string for display - public static string BoolToYesNo(bool value) => value ? "YES" : "NO"; - public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged(string propertyName) { diff --git a/src/PlayerManagement/GroupCollection.cs b/src/PlayerManagement/GroupCollection.cs index efb9bbd..d8deb47 100644 --- a/src/PlayerManagement/GroupCollection.cs +++ b/src/PlayerManagement/GroupCollection.cs @@ -1,6 +1,7 @@ +using Microsoft.EntityFrameworkCore; using System.Collections.Specialized; using System.ComponentModel; -using Microsoft.EntityFrameworkCore; +using Tailgrab.Common; namespace Tailgrab.PlayerManagement { @@ -41,7 +42,7 @@ private void EnsureCount() { var db = _services.GetDBContext(); var query = db.GroupInfos.AsQueryable(); - + if (!string.IsNullOrWhiteSpace(_filterText)) { if (_filterText.StartsWith("grp_", StringComparison.OrdinalIgnoreCase)) @@ -53,7 +54,7 @@ private void EnsureCount() query = query.Where(g => EF.Functions.Like(g.GroupName, $"%{_filterText}%")); } } - + _count = query.Count(); } catch @@ -75,7 +76,7 @@ private void EnsureCount() var db = _services.GetDBContext(); var skip = page * _pageSize; var query = db.GroupInfos.AsQueryable(); - + if (!string.IsNullOrWhiteSpace(_filterText)) { if (_filterText.StartsWith("grp_", StringComparison.OrdinalIgnoreCase)) @@ -87,7 +88,7 @@ private void EnsureCount() query = query.Where(g => EF.Functions.Like(g.GroupName, $"%{_filterText}%")); } } - + var items = query.OrderBy(a => a.GroupName).Skip(skip).Take(_pageSize).ToList(); list = items.Select(a => new GroupInfoViewModel(a)).ToList(); _pages[page] = list; @@ -155,37 +156,29 @@ public class GroupInfoViewModel : INotifyPropertyChanged { public string GroupId { get; set; } public string GroupName { get; set; } - private bool _isBos; - public bool IsBos + private AlertTypeEnum _AlertType; + public AlertTypeEnum AlertType { - get => _isBos; + get => _AlertType; set { - if (_isBos != value) + if (_AlertType != value) { - _isBos = value; - IsBosText = BoolToYesNo(_isBos); - OnPropertyChanged(nameof(IsBos)); - OnPropertyChanged(nameof(IsBosText)); + _AlertType = value; + OnPropertyChanged(nameof(AlertType)); } } } - - public string IsBosText { get; set; } public DateTime? UpdatedAt { get; set; } public GroupInfoViewModel(Tailgrab.Models.GroupInfo a) { GroupId = a.GroupId; GroupName = a.GroupName; - IsBos = a.IsBos; + AlertType = a.AlertType; UpdatedAt = a.UpdatedAt; - IsBosText = BoolToYesNo(IsBos); } - // Convert boolean to YES/NO string for display - public static string BoolToYesNo(bool value) => value ? "YES" : "NO"; - public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged(string propertyName) { diff --git a/src/PlayerManagement/MinutesToHoursMinutesConverter.cs b/src/PlayerManagement/MinutesToHoursMinutesConverter.cs new file mode 100644 index 0000000..4178a97 --- /dev/null +++ b/src/PlayerManagement/MinutesToHoursMinutesConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Tailgrab.PlayerManagement +{ + public class MinutesToHoursMinutesConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double minutes) + { + int totalMinutes = (int)Math.Round(minutes); + int hours = totalMinutes / 60; + int mins = totalMinutes % 60; + return $"{hours}:{mins:D2}"; + } + return "0:00"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/PlayerManagement/PlayerManagement.cs b/src/PlayerManagement/PlayerManagement.cs index d53e1d4..9cc7b1f 100644 --- a/src/PlayerManagement/PlayerManagement.cs +++ b/src/PlayerManagement/PlayerManagement.cs @@ -1,8 +1,7 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.VisualBasic.ApplicationServices; using NLog; +using System.ComponentModel; using System.Text; -using System.Windows; +using Tailgrab.AvatarManagement; using Tailgrab.Clients.VRChat; using Tailgrab.Common; using Tailgrab.LineHandler; @@ -66,9 +65,10 @@ public class PlayerPrint 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) + public PlayerPrint(VRChat.API.Model.Print p, string aiEvaluation, string aiClassification) { PrintId = p.Id; OwnerId = p.OwnerId; @@ -77,11 +77,47 @@ public PlayerPrint(VRChat.API.Model.Print p, string aiEvaluation) PrintUrl = p.Files.Image; AuthorName = p.AuthorName; AIEvaluation = aiEvaluation; + AIClass = aiClassification; } } - public class Player + public class AlertMessage { + 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 class PlayerAvatar + { + public string AvatarName { get; set; } + public string? CreatedBy { get; set; } + public PlayerAvatar(string avatarName, string createdBy) + { + AvatarName = avatarName; + CreatedBy = createdBy; + } + } + + public class Player : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + 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; } @@ -97,56 +133,113 @@ public class Player public Dictionary PrintData = new Dictionary(); public string? UserBio { get; set; } public string? AIEval { get; set; } - public bool IsWatched + + public List _AlertMessage = new List(); + + public string AlertMessage { get { - if (IsAvatarWatch || IsGroupWatch || IsProfileWatch || IsEmojiWatch || IsPrintWatch) + string message = ""; + + _AlertMessage.Sort((p1, p2) => { - return true; + int result = p2.AlertType.CompareTo(p1.AlertType); + if (result == 0) + { + result = p2.Timestamp.CompareTo(p1.Timestamp); + } + return result; + }); + + foreach (AlertMessage alert in _AlertMessage) + { + message += $"[{alert.AlertClass}/{alert.AlertType}] {alert.Message}; "; } - return false; + return message; } } - public string WatchCode + public string AlertColor { get; private set; } = "None"; + + public AlertTypeEnum MaxAlertType { get; private set; } = AlertTypeEnum.None; + + + private DateOnly? _dateJoined; + public DateOnly? DateJoined { - get + get => _dateJoined; + set { - string code = ""; - - if (IsAvatarWatch) - { - code += "A"; - } - if (IsGroupWatch) + if (_dateJoined != value) { - code += "G"; + _dateJoined = value; + OnPropertyChanged(nameof(DateJoined)); + OnPropertyChanged(nameof(ProfileElapsedTime)); } - if (IsProfileWatch) - { - code += "B"; - } - if (IsEmojiWatch) + } + } + + public string ProfileElapsedTime + { + get + { + try { - code += "E"; + DateTime joinDate = DateTime.Parse(_dateJoined.ToString() ?? new DateTime().ToString()); + TimeSpan elapsed = DateTime.Now - joinDate; + + // If >= 1 year, show years + if (elapsed.TotalDays >= 365) + { + double years = elapsed.TotalDays / 365.25; // Account for leap years + return $"{years:F1}Y"; + } + else if( elapsed.TotalDays >= 30) + { + double months = elapsed.TotalDays / 30.44; // Average days per month + return $"{months:F1}M"; + } + else if (elapsed.TotalDays >= 7) + { + double weeks = elapsed.TotalDays / 7; + return $"{weeks:F1}W"; + } + else if (elapsed.TotalDays >= 1) + { + double days = elapsed.TotalDays; + return $"{days:F1}D"; + } + else if (elapsed.TotalDays < 1) + { + double hours = elapsed.Hours; + return $"{hours:F1}H"; + } } - if (IsPrintWatch) + catch { - code += "B"; + return "N/A"; } - return code; + return "N/A"; } } - public bool IsAvatarWatch { get; set; } = false; - public bool IsGroupWatch { get; set; } = false; - public bool IsProfileWatch { get; set; } = false; - public bool IsEmojiWatch { get; set; } = false; - public bool IsPrintWatch { get; set; } = false; + // This goes away with the new alert system, but for now it is used to track if any of the watch types are active for a player + public bool IsWatched + { + get + { + if (_AlertMessage.Count() > 0) + { + return true; + } + + return false; + } + } public Player(string userId, string displayName, SessionInfo session) { @@ -158,6 +251,23 @@ public Player(string userId, string displayName, SessionInfo session) 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.Add(newAlert); + + foreach (AlertMessage alert in _AlertMessage) + { + if (alert.AlertType > MaxAlertType) + { + MaxAlertType = alert.AlertType; + AlertColor = alert.Color; + } + } + } + public void AddEvent(PlayerEvent playerEvent) { Events.Add(playerEvent); @@ -258,6 +368,7 @@ public class PlayerManager private static Dictionary userIdByNetworkId = new Dictionary(); private static Dictionary userIdByDisplayName = new Dictionary(); private static Dictionary avatarByDisplayName = new Dictionary(); + private static Dictionary PlayerAvatarByName = new Dictionary(); public static readonly AnsiColor COLOR_PREFIX_LEAVE = AnsiColor.Yellow; public static readonly AnsiColor COLOR_PREFIX_JOIN = AnsiColor.Green; @@ -315,7 +426,7 @@ public void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, string try { Player? player = GetPlayerByDisplayName(displayName); - if (player != null ) + if (player != null) { PlayerChanged?.Invoke(null, new PlayerChangedEventArgs(changeType, player)); } @@ -393,13 +504,14 @@ public void PlayerLeft(string displayName, AbstractLineHandler handler) TimeSpan timeDifference = (TimeSpan)(player.InstanceEndTime - player.InstanceStartTime); logger.Debug($"{displayName} session time: {timeDifference.TotalMinutes} minutes"); TailgrabDBContext dBContext = serviceRegistry.GetDBContext(); + + // Update or create UserInfo record with elapsed time UserInfo? user = dBContext.UserInfos.Find(player.UserId); if (user == null) { user = new UserInfo(); user.DisplayName = player.DisplayName; user.UserId = player.UserId; - user.IsBos = 0; user.CreatedAt = DateTime.Now; user.UpdatedAt = DateTime.Now; user.ElapsedMinutes = timeDifference.TotalMinutes; @@ -462,6 +574,7 @@ public void ClearAllPlayers(AbstractLineHandler handler) userIdByNetworkId.Clear(); playersByUserId.Clear(); userIdByDisplayName.Clear(); + PlayerAvatarByName.Clear(); // Also a global cleared notification (consumers may want to reset) OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Cleared, new Player("", "", CurrentSession) { InstanceStartTime = DateTime.MinValue }); @@ -486,7 +599,7 @@ public void LogAllPlayers(AbstractLineHandler handler) public Player? AddPlayerEventByDisplayName(string displayName, PlayerEvent.EventType eventType, string eventDescription) { - if(userIdByDisplayName.TryGetValue(displayName, out string? userId)) + if (userIdByDisplayName.TryGetValue(displayName, out string? userId)) { return AddPlayerEventByUserId(userId, eventType, eventDescription); } @@ -510,28 +623,49 @@ public void SetAvatarForPlayer(string displayName, string avatarName) { avatarByDisplayName[displayName] = avatarName; - bool watchedAvatar = serviceRegistry.GetAvatarManager().CheckAvatarByName(avatarName); - if (watchedAvatar) + Player? player = AddPlayerEventByDisplayName(displayName, PlayerEvent.EventType.AvatarWatch, $"User switched to Avatar : {avatarName}"); ; + if (player != null) { - logger.Info($"{COLOR_PREFIX_LEAVE.GetAnsiEscape()}Watched Avatar Detected for Player {displayName}: {avatarName}{COLOR_RESET.GetAnsiEscape()}"); + AvatarInfo? watchedAvatar = serviceRegistry.GetAvatarManager().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()}"); + if (watchedAvatar.AlertType > AlertTypeEnum.None) + { + player = AddPlayerEventByDisplayName(displayName, PlayerEvent.EventType.AvatarWatch, $"User has used a watched Avatar : {avatarName} alertType: {watchedAvatar.AlertType.ToString()}"); + player?.AddAlertMessage(AlertClassEnum.Avatar, watchedAvatar.AlertType, $"{avatarName}"); + } + } + if (player != null) + { + player.AvatarName = avatarName; + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); + } } + } - Player? player = GetPlayerByDisplayName(displayName); - if (player != null) - { - player.IsAvatarWatch = watchedAvatar; - player.AvatarName = avatarName; - AddPlayerEventByDisplayName(displayName ?? string.Empty, PlayerEvent.EventType.AvatarWatch, $"User switched to Avatar : {avatarName}"); + public void UnpackAvatar(string avatarName, string uploadedBy) + { + PlayerAvatar playerAvatar = UpdatePlayerAvatar(avatarName, uploadedBy); - if (watchedAvatar) - { - player.PenActivity = $"AV: {avatarName}"; - AddPlayerEventByDisplayName(displayName ?? string.Empty, PlayerEvent.EventType.AvatarWatch, $"User has used a watched Avatar : {avatarName}"); + // + // Check Avatar IDs + } - } + public PlayerAvatar UpdatePlayerAvatar(string avatarName, string uploadedBy) + { - OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); + if (PlayerAvatarByName.TryGetValue(avatarName, out PlayerAvatar? playerAvatar)) + { + return playerAvatar; } + else + { + playerAvatar = new PlayerAvatar(avatarName, uploadedBy); + PlayerAvatarByName[avatarName] = playerAvatar; + + } + return playerAvatar; } private void PrintPlayerInfo(Player player) @@ -558,7 +692,8 @@ internal async void AddInventorySpawn(string userId, string inventoryId) string itemUrl = ""; string itemContent = ""; string inventoryType = "Unknown Type"; - string aiEvaluation = "OK"; + string aiClassification = "OK"; + string evaluatedText = "Not Evaluated"; try { @@ -583,21 +718,21 @@ internal async void AddInventorySpawn(string userId, string inventoryId) var ollamaClient = serviceRegistry.GetOllamaAPIClient(); if (ollamaClient != null) { - string? evaluated = await ollamaClient.ClassifyImageList(userId, inventoryId, new List{ itemUrl, itemContent }); + ImageEvaluation? evaluated = await ollamaClient.ClassifyImageList(userId, inventoryId, new List { itemUrl, itemContent }); if (evaluated != null) { - aiEvaluation = EvaluatImage(evaluated) ?? "OK"; - logger.Info($"Ollama classification for inventory item {inventoryId}: {aiEvaluation}: {evaluated}"); - if (!aiEvaluation.Equals("OK")) + 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) { - AddPlayerEventByUserId(userId, PlayerEvent.EventType.Emoji, $"AI Evaluation: Spawned Item {itemName} ({inventoryId}) was classified {evaluated}"); - player.PenActivity = $"EM: {aiEvaluation}"; - player.IsEmojiWatch = true; + AddPlayerEventByUserId(userId, PlayerEvent.EventType.Emoji, $"AI Evaluation: Spawned Item {itemName} ({inventoryId}) was classified {aiClassification}"); + player.AddAlertMessage(AlertClassEnum.EmojiSticker, AlertTypeEnum.Nuisance, $"{aiClassification}"); } } } - PlayerInventory inventory = new PlayerInventory(inventoryId, itemName, itemUrl, inventoryType, aiEvaluation); + PlayerInventory inventory = new PlayerInventory(inventoryId, itemName, itemContent, inventoryType, aiClassification); player.Inventory.Add(inventory); AddPlayerEventByUserId(userId, PlayerEvent.EventType.Emoji, $"Spawned Item: {itemName} ({inventoryId})"); @@ -606,7 +741,7 @@ internal async void AddInventorySpawn(string userId, string inventoryId) } } - private static string? EvaluatImage(string? imageEvaluation) + private static string? EvaluateImageClass(string? imageEvaluation) { if (string.IsNullOrEmpty(imageEvaluation)) { @@ -665,37 +800,83 @@ internal async void AddPrintData(string printId) Print? printInfo = serviceRegistry.GetVRChatAPIClient().GetPrintInfo(printId); if (printInfo != null) { - if (playersByUserId.TryGetValue(printInfo.OwnerId, out Player? player)) + Player? player = AddPlayerEventByUserId(printInfo.OwnerId, PlayerEvent.EventType.Print, $"Dropped Print {printId}"); + if (player != null) { - string? evaluated = string.Empty; + 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) { - string aiEvaluation = "OK"; List imageUrls = new List(); imageUrls.Add(printInfo.Files.Image); evaluated = await ollamaClient.ClassifyImageList(printInfo.OwnerId, printInfo.Id, imageUrls); if (evaluated != null) { - aiEvaluation = EvaluatImage(evaluated) ?? "OK"; - logger.Info($"Ollama classification for inventory item {printInfo.Id}: {aiEvaluation}: {evaluated}"); - if( !aiEvaluation.Equals("OK")) + evaluatedText = System.Text.Encoding.UTF8.GetString(evaluated.Evaluation); + aiClassification = EvaluateImageClass(System.Text.Encoding.UTF8.GetString( evaluated.Evaluation)) ?? "OK"; + logger.Info($"Ollama classification for inventory item {printInfo.Id}: {aiClassification}: {evaluatedText}"); + if (!aiClassification.Equals("OK") && !evaluated.IsIgnored) { - AddPlayerEventByUserId(printInfo.OwnerId, PlayerEvent.EventType.Print, $"AI Evaluation: Print {printId} was classified {evaluated}"); - player.PenActivity = "PR: " + aiEvaluation; - player.IsPrintWatch = true; - } + player = AddPlayerEventByUserId(printInfo.OwnerId, PlayerEvent.EventType.Print, $"AI Evaluation: Print {printId} was classified {aiClassification}"); + player?.AddAlertMessage(AlertClassEnum.Print, AlertTypeEnum.Nuisance, $"{aiClassification}"); + } } } - player.PrintData[printId] = new PlayerPrint( printInfo, evaluated ?? "Not Evaluated" ); - logger.Info($"Added Print {printId} for Player {player.DisplayName} (ID: {printInfo.OwnerId})"); + player?.PrintData[printId] = new PlayerPrint(printInfo, evaluatedText, aiClassification); } - AddPlayerEventByUserId(printInfo.OwnerId, PlayerEvent.EventType.Print, $"Dropped Print {printId}"); } } } + + public Player? UpdatePlayerUserFromVRCProfile(User profile, string profileHash) + { + if (profile != null) + { + TailgrabDBContext dbContext = serviceRegistry.GetDBContext(); + Player? player = GetPlayerByUserId(profile.Id); + if (player != null) + { + player.DateJoined = profile.DateJoined; + logger.Info($"Updated UserInfo for user {profile.DisplayName} (ID: {profile.Id}) with DateJoined: {profile.DateJoined} and ProfileHash: {profileHash}; {player.ProfileElapsedTime}"); + } + + // Update or create UserInfo record with elapsed time + UserInfo? user = dbContext.UserInfos.Find(profile.Id); + if (user != null) + { + user.DateJoined = profile.DateJoined; + user.UpdatedAt = DateTime.UtcNow; + user.LastProfileChecksum = profileHash; + dbContext.UserInfos.Update(user); + } + 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; + dbContext.Add(user); + } + dbContext.SaveChanges(); + + + return player; + } + else + { + logger.Warn($"Attempted to update player user info from VRC profile, but profile was null"); + return null; + } + } + public GroupInfo? AddUpdateGroupFromVRC(string? groupId) { if (string.IsNullOrEmpty(groupId)) @@ -716,8 +897,7 @@ internal async void AddPrintData(string printId) GroupId = group.Id, GroupName = group.Name ?? string.Empty, CreatedAt = group.CreatedAt, - UpdatedAt = DateTime.UtcNow, - IsBos = false + UpdatedAt = DateTime.UtcNow }; dbContext.GroupInfos.Add(newEntity); @@ -744,5 +924,58 @@ internal async void AddPrintData(string printId) return null; } + + public void SyncAvatarModerations() + { + try + { + TailgrabDBContext dBContext = serviceRegistry.GetDBContext(); + VRChatClient vrcClient = serviceRegistry.GetVRChatAPIClient(); + if (dBContext != null && vrcClient != null) + { + int lineNumber = 0; + List moderations = vrcClient.GetAvatarModerations(); + foreach (VRChat.API.Model.AvatarModeration mod in moderations) + { + logger.Debug($"Processing Avatar Moderation for Avatar ID {mod.TargetAvatarId} with Status {mod.AvatarModerationType} and CreatedAt {mod.Created}"); + if (mod != null && mod.AvatarModerationType.Equals(AvatarModerationType.Block)) + { + + lineNumber++; + 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); + } + } + } + } + } + catch (Exception ex) + { + logger.Error(ex, "Failed to clear the database"); + } + } + + #region Alert Color Management + public static string GetAlertColor(AlertClassEnum alertClass, AlertTypeEnum alertType) + { + string alertKey = alertClass switch + { + AlertClassEnum.Avatar => CommonConst.Avatar_Alert_Key, + AlertClassEnum.Group => CommonConst.Group_Alert_Key, + AlertClassEnum.Profile => CommonConst.Profile_Alert_Key, + AlertClassEnum.Print => CommonConst.Profile_Alert_Key, + AlertClassEnum.EmojiSticker => CommonConst.Profile_Alert_Key, + _ => CommonConst.Profile_Alert_Key + + }; + + string key = CommonConst.ConfigRegistryPath + "\\" + alertKey + "\\" + alertType.ToString(); + return ConfigStore.GetStoredKeyString(key, CommonConst.Color_Alert_Key) ?? "None"; + } + + #endregion } } diff --git a/src/PlayerManagement/SoundNotNoneConverter.cs b/src/PlayerManagement/SoundNotNoneConverter.cs new file mode 100644 index 0000000..6b9558e --- /dev/null +++ b/src/PlayerManagement/SoundNotNoneConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Tailgrab.PlayerManagement +{ + public class SoundNotNoneConverter : IMultiValueConverter + { + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length > 0 && values[0] is string soundValue) + { + return !string.IsNullOrWhiteSpace(soundValue) && + !soundValue.Equals("*NONE", StringComparison.OrdinalIgnoreCase); + } + return false; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/PlayerManagement/TailgrabPanel.xaml b/src/PlayerManagement/TailgrabPanel.xaml index f38b324..ef018e4 100644 --- a/src/PlayerManagement/TailgrabPanel.xaml +++ b/src/PlayerManagement/TailgrabPanel.xaml @@ -9,6 +9,10 @@ Title="Tailgrab Player Panel" WindowStartupLocation="CenterOwner" UseLayoutRounding="True" TextOptions.TextFormattingMode="Display"> + + + + @@ -19,35 +23,53 @@ Title="Tailgrab Player Panel" - - - - - - + + + + + + + + + + + + + - static List OpenedFiles = new List { }; + static Dictionary WatchedFiles = new Dictionary(); /// /// The path to the user's profile directory. @@ -43,19 +45,113 @@ public class FileTailer static Logger logger = LogManager.GetCurrentClassLogger(); static ServiceRegistry? _serviceRegistry; + // At the class level, add a dictionary to track active tail tasks + static Dictionary ActiveTailTasks = new Dictionary(); + + /// + /// Watch the VRChat log directory by default and process logs. + /// Show the TailgrabPanel UI on the STA thread before continuing to watch files. + /// + [STAThread] + public static void Main(string[] args) + { + //Early in your program do something like this: + NLog.GlobalDiagnosticsContext.Set("StartTime", DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss")); + string configFilePath = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "NLog.config"); + LogManager.Setup().LoadConfigurationFromFile(configFilePath); + + // Basic command line parsing: + // -l : use explicit log folder/file path + // -clear : remove application registry settings and exit + // -backup : create a backup of the database and exit + string? explicitPath = null; + bool clearRegistry = false; + bool upgrade = false; + bool backup = false; + for (int i = 0; i < args.Length; i++) + { + var a = args[i]; + if (string.Equals(a, "-l", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + explicitPath = args[i + 1]; + i++; + } + else if (string.Equals(a, "-clear", StringComparison.OrdinalIgnoreCase)) + { + clearRegistry = true; + } + else if (string.Equals(a, "-backup", StringComparison.OrdinalIgnoreCase)) + { + backup = true; + } + else if (string.Equals(a, "-upgrade", StringComparison.OrdinalIgnoreCase)) + { + upgrade = true; + } + } + + if (clearRegistry) + { + DeleteTailgrabRegistrySettings(); + + // Exit application after clearing settings + return; + } + + _serviceRegistry = new ServiceRegistry(); + _serviceRegistry.StartAllServices(); + + if (upgrade) + { + UpgradeApplication(_serviceRegistry); + } + + if (backup) + { + CreateDatabaseBackup(); + + // Exit application after creating backup + return; + } + + string filePath = GetLogsPath(args, explicitPath); + if (!Directory.Exists(filePath)) + { + logger.Info($"Missing VRChat log directory at '{filePath}'"); + return; + } + + ConfigurationManager configurationManager = new ConfigurationManager(_serviceRegistry); + configurationManager.LoadLineHandlersFromConfig(HandlerList); + + // Start the watcher task on a background thread so it doesn't block the STA UI thread + logger.Info($"Starting file watcher and showing UI for: '{filePath}'"); + _ = Task.Run(() => WatchPath(filePath, _serviceRegistry)); + + // Start the Amplitude Cache watcher task on a background thread + string ampPath = VRChatAmplitudePath + Path.DirectorySeparatorChar; + logger.Info($"Starting Amplitude Cache watcher for: '{ampPath}'"); + _ = Task.Run(() => WatchAmpCache(ampPath, _serviceRegistry)); + + //SyncAvatarModerations(_serviceRegistry); + + BuildAppWindow(_serviceRegistry); + + // When the window closes, allow Main to complete. The watcher task will be abandoned; if desired add cancellation. + } /// /// Threaded tailing of a file, reading new lines as they are added. /// - public static async Task TailFileAsync(string filePath) + public static async Task TailFileAsync(string filePath) { if (OpenedFiles.Contains(filePath)) { - return; + return null; } - OpenedFiles.Add(filePath); - logger.Info($"Tailing file: {filePath}. Press Ctrl+C to stop."); + var status = new FileTailStatus(filePath); + logger.Info($"Tailing file: {filePath}"); using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (StreamReader sr = new StreamReader(fs, Encoding.UTF8)) @@ -64,19 +160,35 @@ public static async Task TailFileAsync(string filePath) long lastMaxOffset = fs.Length; fs.Seek(lastMaxOffset, SeekOrigin.Begin); - while (true) + OpenedFiles.Add(filePath); + WatchedFiles[filePath] = new FileWatchItem(lastMaxOffset); + + while (!status.IsCancellationRequested) { // If the file size hasn't changed, wait if (fs.Length == lastMaxOffset) { - await Task.Delay(100); // Adjust delay as needed + if (WatchedFiles.ContainsKey(filePath)) + { + WatchedFiles[filePath].ElapsedTime += 1; + if (WatchedFiles[filePath].ElapsedTime >= 9000) // If we've been watching this file for 15 minutes without changes + { + logger.Info($"Timeout waiting for new lines in '{filePath}'"); + break; + } + } + + await Task.Delay(100, status.CancellationSource.Token).ConfigureAwait(false); continue; } // Read and display new lines string? line; - while ((line = await sr.ReadLineAsync()) != null) + while ((line = await sr.ReadLineAsync().ConfigureAwait(false)) != null) { + if (status.IsCancellationRequested) + break; + foreach (ILineHandler handler in HandlerList) { if (handler.HandleLine(line)) @@ -84,18 +196,26 @@ public static async Task TailFileAsync(string filePath) break; } } + + status.IncrementLinesProcessed(); } // Update the offset to the new end of the file lastMaxOffset = fs.Length; + + // Reset the watch counter for this file since we have new data + WatchedFiles.Remove(filePath); } } + + logger.Info($"Stopped tailing file: {filePath}. Total lines processed: {status.LinesProcessed}"); + return status; } /// /// Watch and dispatch File Tailing. /// - public static async Task WatchPath(string path) + public static async Task WatchPath(string path, ServiceRegistry _serviceRegistry) { LogWatcher.Path = Path.GetDirectoryName(path)!; LogWatcher.Filter = Path.GetFileName("output_log*.txt"); @@ -103,12 +223,20 @@ public static async Task WatchPath(string path) LogWatcher.Created += async (source, e) => { - await TailFileAsync(e.FullPath); + var status = await TailFileAsync(e.FullPath); + if (status != null) + { + ActiveTailTasks[e.FullPath] = status; + } }; LogWatcher.Changed += async (source, e) => { - await TailFileAsync(e.FullPath); + var status = await TailFileAsync(e.FullPath); + if (status != null) + { + ActiveTailTasks[e.FullPath] = status; + } }; LogWatcher.EnableRaisingEvents = true; @@ -116,11 +244,20 @@ public static async Task WatchPath(string path) // ensure existing files are tailed immediately try { - string _todaysFile = $"output_log_{DateTime.Now:yyyy_MM_dd}*.txt"; + string _todaysFile = $"output_log_{DateTime.Now:yyyy-MM-dd}_*.txt"; + logger.Debug($"Looking for existing log files matching: {_todaysFile}"); var existing = Directory.GetFiles(LogWatcher.Path, _todaysFile); foreach (var f in existing) { - _ = Task.Run(() => TailFileAsync(f)); + logger.Debug($"Found File to Tail: {f}"); + _ = Task.Run(async () => + { + var status = await TailFileAsync(f); + if (status != null) + { + ActiveTailTasks[f] = status; + } + }); } } catch @@ -128,7 +265,6 @@ public static async Task WatchPath(string path) /* ignore errors */ } - // Keep the application running to monitor changes await Task.Delay(Timeout.Infinite); } @@ -209,109 +345,13 @@ public static async Task WatchAmpCache(string path, ServiceRegistry serviceRegis await Task.Delay(Timeout.Infinite); } - /// - /// Watch the VRChat log directory by default and process logs. - /// Show the TailgrabPanel UI on the STA thread before continuing to watch files. - /// - [STAThread] - public static void Main(string[] args) - { - - // Basic command line parsing: - // -l : use explicit log folder/file path - // -clear : remove application registry settings and exit - // -backup : create a backup of the database and exit - string? explicitPath = null; - bool clearRegistry = false; - bool upgrade = false; - bool backup = false; - for (int i = 0; i < args.Length; i++) - { - var a = args[i]; - if (string.Equals(a, "-l", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) - { - explicitPath = args[i + 1]; - i++; - } - else if (string.Equals(a, "-clear", StringComparison.OrdinalIgnoreCase)) - { - clearRegistry = true; - } - else if (string.Equals(a, "-backup", StringComparison.OrdinalIgnoreCase)) - { - backup = true; - } - else if (string.Equals(a, "-upgrade", StringComparison.OrdinalIgnoreCase)) - { - upgrade = true; - } - } - - if (clearRegistry) - { - DeleteTailgrabRegistrySettings(); - - // Exit application after clearing settings - return; - } - - CreateResourceDirectory(); - - _serviceRegistry = new ServiceRegistry(); - _serviceRegistry.StartAllServices(); - - if ( upgrade ) - { - UpgradeApplication(_serviceRegistry); - } - - if (backup) - { - CreateDatabaseBackup(); - - // Exit application after creating backup - return; - } - - string filePath = GetLogsPath(args, explicitPath); - if (!Directory.Exists(filePath)) - { - logger.Info($"Missing VRChat log directory at '{filePath}'"); - return; - } - - AvatarBosGistListManager avatarGistMgr = new AvatarBosGistListManager(_serviceRegistry); - _ = Task.Run(() => avatarGistMgr.ProcessAvatarGistList()); - - GroupBosGistListManager groupGistMgr = new GroupBosGistListManager(_serviceRegistry); - _ = Task.Run(() => groupGistMgr.ProcessGroupGistList()); - - ConfigurationManager configurationManager = new ConfigurationManager(_serviceRegistry); - configurationManager.LoadLineHandlersFromConfig(HandlerList); - - // Start the watcher task on a background thread so it doesn't block the STA UI thread - logger.Info($"Starting file watcher and showing UI for: '{filePath}'"); - _ = Task.Run(() => WatchPath(filePath)); - - // Start the Amplitude Cache watcher task on a background thread - string ampPath = VRChatAmplitudePath + Path.DirectorySeparatorChar; - logger.Info($"Starting Amplitude Cache watcher for: '{ampPath}'"); - _ = Task.Run(() => WatchAmpCache(ampPath, _serviceRegistry)); - - //SyncAvatarModerations(_serviceRegistry); - - BuildAppWindow(_serviceRegistry); - - // When the window closes, allow Main to complete. The watcher task will be abandoned; if desired add cancellation. - } - private static void UpgradeApplication(ServiceRegistry serviceRegistry) { logger.Warn($"Starting application upgrade process..."); - + // Migrate database schema while preserving data serviceRegistry.GetDBContext().Database.Migrate(); - + // Create missing registry items with default values InitializeMissingRegistryItems(); @@ -326,7 +366,7 @@ private static void InitializeMissingRegistryItems() { try { - using var key = Registry.CurrentUser.CreateSubKey(Tailgrab.Common.Common.ConfigRegistryPath); + using var key = Registry.CurrentUser.CreateSubKey(Tailgrab.Common.CommonConst.ConfigRegistryPath); if (key == null) { logger.Warn("Failed to create or open registry key for configuration."); @@ -344,14 +384,14 @@ void SetDefaultIfMissing(string name, string defaultValue) } // Initialize Ollama API registry keys with defaults - SetDefaultIfMissing(Tailgrab.Common.Common.Registry_Ollama_API_Endpoint, - Tailgrab.Common.Common.Default_Ollama_API_Endpoint); - SetDefaultIfMissing(Tailgrab.Common.Common.Registry_Ollama_API_Model, - Tailgrab.Common.Common.Default_Ollama_API_Model); - SetDefaultIfMissing(Tailgrab.Common.Common.Registry_Ollama_API_Prompt, - Tailgrab.Common.Common.Default_Ollama_API_Prompt); - SetDefaultIfMissing(Tailgrab.Common.Common.Registry_Ollama_API_Image_Prompt, - Tailgrab.Common.Common.Default_Ollama_API_Image_Prompt); + SetDefaultIfMissing(Tailgrab.Common.CommonConst.Registry_Ollama_API_Endpoint, + Tailgrab.Common.CommonConst.Default_Ollama_API_Endpoint); + SetDefaultIfMissing(Tailgrab.Common.CommonConst.Registry_Ollama_API_Model, + Tailgrab.Common.CommonConst.Default_Ollama_API_Model); + SetDefaultIfMissing(Tailgrab.Common.CommonConst.Registry_Ollama_API_Prompt, + Tailgrab.Common.CommonConst.Default_Ollama_API_Prompt); + SetDefaultIfMissing(Tailgrab.Common.CommonConst.Registry_Ollama_API_Image_Prompt, + Tailgrab.Common.CommonConst.Default_Ollama_API_Image_Prompt); // Note: The following keys don't have default values and should be set by the user: // - Registry_VRChat_Web_UserName @@ -374,55 +414,6 @@ void SetDefaultIfMissing(string name, string defaultValue) } } - private static void SyncAvatarModerations(ServiceRegistry serviceRegistry) - { - try - { - TailgrabDBContext dBContext = serviceRegistry.GetDBContext(); - VRChatClient vrcClient = serviceRegistry.GetVRChatAPIClient(); - if( dBContext != null && vrcClient != null ) - { - List moderations = vrcClient.GetAvatarModerations(); - foreach (VRChat.API.Model.AvatarModeration mod in moderations) - { - AvatarInfo? existing = dBContext.AvatarInfos.FirstOrDefault(a => a.AvatarId == mod.TargetAvatarId); - if (existing != null) - { - if (existing.IsBos) - { - logger.Debug($"Avatar {existing.AvatarId} is already marked as BOS in the database. Skipping update."); - continue; // already marked as BOS, no update needed - } - - logger.Debug($"Marking Avatar {existing.AvatarId} is as BOS in the database."); - existing.IsBos = true; - existing.UpdatedAt = mod.Created; - dBContext.SaveChanges(); - - } - else - { - dBContext.AvatarInfos.Add(new AvatarInfo - { - AvatarName = "From Moderation API", - AvatarId = mod.TargetAvatarId, - IsBos = true, - CreatedAt = mod.Created, - UpdatedAt = mod.Created - }); - dBContext.SaveChanges(); - - logger.Debug($"Adding missing Avatar {mod.TargetAvatarId} is as BOS in the database."); - } - } - } - } - catch (Exception ex) - { - logger.Error(ex, "Failed to clear the database"); - } - } - private static string GetLogsPath(string[] args, string? explicitPath) { string filePath = explicitPath ?? (VRChatAppDataPath + Path.DirectorySeparatorChar); @@ -482,23 +473,6 @@ private static void CreateDatabaseBackup() { try { - var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); - var databasePath = Path.Combine(dataDir, "avatars.sqlite"); - - if (!File.Exists(databasePath)) - { - logger.Warn($"Database file not found at '{databasePath}'. Nothing to backup."); - return; - } - - // Create backup directory with timestamp - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - var backupDirName = $"backup_{timestamp}"; - var backupDir = Path.Combine(dataDir, backupDirName); - Directory.CreateDirectory(backupDir); - - logger.Info($"Creating database backup in: '{backupDirName}'"); - // Get database context if (_serviceRegistry == null) { @@ -508,80 +482,8 @@ private static void CreateDatabaseBackup() var context = _serviceRegistry.GetDBContext(); - // Export each table to JSON - int totalRecords = 0; - - // Export AvatarInfo table - logger.Info("Exporting AvatarInfo table..."); - var avatars = context.AvatarInfos.ToList(); - var avatarsJson = JsonSerializer.Serialize(avatars, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(backupDir, "AvatarInfo.json"), avatarsJson); - logger.Info($" Exported {avatars.Count} avatar records"); - totalRecords += avatars.Count; - - // Export GroupInfo table - logger.Info("Exporting GroupInfo table..."); - var groups = context.GroupInfos.ToList(); - var groupsJson = JsonSerializer.Serialize(groups, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(backupDir, "GroupInfo.json"), groupsJson); - logger.Info($" Exported {groups.Count} group records"); - totalRecords += groups.Count; - - // Export UserInfo table - logger.Info("Exporting UserInfo table..."); - var users = context.UserInfos.ToList(); - var usersJson = JsonSerializer.Serialize(users, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(backupDir, "UserInfo.json"), usersJson); - logger.Info($" Exported {users.Count} user records"); - totalRecords += users.Count; - - // Export ProfileEvaluation table - logger.Info("Exporting ProfileEvaluation table..."); - var profiles = context.ProfileEvaluations.ToList(); - var profilesJson = JsonSerializer.Serialize(profiles, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(backupDir, "ProfileEvaluation.json"), profilesJson); - logger.Info($" Exported {profiles.Count} profile evaluation records"); - totalRecords += profiles.Count; - - // Export ImageEvaluation table - logger.Info("Exporting ImageEvaluation table..."); - var images = context.ImageEvaluations.ToList(); - var imagesJson = JsonSerializer.Serialize(images, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(backupDir, "ImageEvaluation.json"), imagesJson); - logger.Info($" Exported {images.Count} image evaluation records"); - totalRecords += images.Count; - - // Create backup metadata file - var metadata = new - { - BackupTimestamp = timestamp, - BackupDate = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), - ApplicationVersion = BuildInfo.GetInformationalVersion(), - DatabasePath = databasePath, - TotalRecords = totalRecords, - Tables = new[] - { - new { TableName = "AvatarInfo", RecordCount = avatars.Count }, - new { TableName = "GroupInfo", RecordCount = groups.Count }, - new { TableName = "UserInfo", RecordCount = users.Count }, - new { TableName = "ProfileEvaluation", RecordCount = profiles.Count }, - new { TableName = "ImageEvaluation", RecordCount = images.Count } - } - }; - var metadataJson = JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(backupDir, "_backup_metadata.json"), metadataJson); - - // Calculate total backup size - var backupDirInfo = new DirectoryInfo(backupDir); - var totalSize = backupDirInfo.GetFiles().Sum(f => f.Length); + context.CreateDatabaseBackup(); - logger.Info($"Database backup completed successfully:"); - logger.Info($" Location: '{backupDir}'"); - logger.Info($" Total records: {totalRecords}"); - logger.Info($" Backup size: {totalSize / 1024.0:F2} KB"); - - // Clean up old backups (keep only last 10) - CleanupOldBackups(dataDir, 10); } catch (Exception ex) { @@ -589,61 +491,6 @@ private static void CreateDatabaseBackup() } } - /// - /// Clean up old backup directories, keeping only the specified number of most recent backups. - /// - private static void CleanupOldBackups(string dataDir, int keepCount) - { - try - { - var backupDirs = Directory.GetDirectories(dataDir, "backup_*") - .Select(d => new DirectoryInfo(d)) - .OrderByDescending(d => d.CreationTime) - .ToList(); - - if (backupDirs.Count > keepCount) - { - var dirsToDelete = backupDirs.Skip(keepCount); - foreach (var dir in dirsToDelete) - { - logger.Info($"Deleting old backup directory: '{dir.Name}'"); - dir.Delete(recursive: true); - } - } - } - catch (Exception ex) - { - logger.Warn(ex, "Failed to clean up old backup directories"); - } - } - - private static void CreateResourceDirectory() - { - // Ensure Resources/tailgrab.ico is present in the application folder. If missing, write a small embedded PNG as the icon file. - try - { - var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); - Directory.CreateDirectory(dataDir); - - var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources"); - Directory.CreateDirectory(resourcesDir); - - var iconPath = Path.Combine(resourcesDir, "tailgrab.ico"); - if (!File.Exists(iconPath)) - { - // A tiny 1x1 PNG (transparent). We'll write it to the .ico path so WPF can load it as an ImageSource. - var base64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII="; - var bytes = Convert.FromBase64String(base64Png); - File.WriteAllBytes(iconPath, bytes); - logger.Info($"Wrote placeholder icon to: {iconPath}"); - } - } - catch (Exception ex) - { - logger.Warn(ex, "Failed to ensure Resources/tailgrab.ico exists"); - } - } - private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) { // Start WPF application and show the TailgrabPanel on this STA thread @@ -653,7 +500,11 @@ private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) var darkWindow = new SolidColorBrush(System.Windows.Media.Color.FromRgb(30, 30, 30)); var darkControl = new SolidColorBrush(System.Windows.Media.Color.FromRgb(45, 45, 48)); var lightText = new SolidColorBrush(System.Windows.Media.Color.FromRgb(230, 230, 230)); - var accent = new SolidColorBrush(System.Windows.Media.Color.FromRgb(0, 122, 204)); + var accent = new SolidColorBrush(System.Windows.Media.Color.FromRgb(29, 44, 55)); + + var highlightDark = new SolidColorBrush(System.Windows.Media.Color.FromRgb(70, 70, 109)); + var highlightDarkText = new SolidColorBrush(System.Windows.Media.Color.FromRgb(200, 200, 129)); + // Override common system brushes app.Resources[System.Windows.SystemColors.WindowBrushKey] = darkWindow; @@ -682,12 +533,13 @@ private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) listViewItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, System.Windows.Media.Brushes.Transparent)); var selectedTrigger = new Trigger { Property = System.Windows.Controls.ListViewItem.IsSelectedProperty, Value = true }; - selectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 63)))); - selectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); + selectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, highlightDark)); + selectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, highlightDarkText)); listViewItemStyle.Triggers.Add(selectedTrigger); var mouseOverTrigger = new Trigger { Property = System.Windows.Controls.ListViewItem.IsMouseOverProperty, Value = true }; - mouseOverTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(50, 50, 53)))); + mouseOverTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, highlightDark)); + mouseOverTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, highlightDarkText)); listViewItemStyle.Triggers.Add(mouseOverTrigger); app.Resources[typeof(System.Windows.Controls.ListViewItem)] = listViewItemStyle; @@ -697,6 +549,17 @@ private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) headerStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkControl)); headerStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); headerStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, accent)); + + var headerMouseOverTrigger = new Trigger { Property = System.Windows.Controls.GridViewColumnHeader.IsMouseOverProperty, Value = true }; + headerMouseOverTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, highlightDark)); + headerMouseOverTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, highlightDarkText)); + headerStyle.Triggers.Add(headerMouseOverTrigger); + + var headerPressedTrigger = new Trigger { Property = System.Windows.Controls.GridViewColumnHeader.IsPressedProperty, Value = true }; + headerPressedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, highlightDark)); + headerPressedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, highlightDarkText)); + headerStyle.Triggers.Add(headerPressedTrigger); + app.Resources[typeof(System.Windows.Controls.GridViewColumnHeader)] = headerStyle; // DataGrid dark theme styles for Config -> Avatars DB @@ -720,8 +583,8 @@ private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) dgCellStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); dgCellStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, System.Windows.Media.Brushes.Transparent)); var cellSelectedTrigger = new Trigger { Property = System.Windows.Controls.DataGridCell.IsSelectedProperty, Value = true }; - cellSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 63)))); - cellSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); + cellSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, highlightDark)); + cellSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, highlightDarkText)); dgCellStyle.Triggers.Add(cellSelectedTrigger); app.Resources[typeof(System.Windows.Controls.DataGridCell)] = dgCellStyle; @@ -729,7 +592,8 @@ private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) dgRowStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkWindow)); dgRowStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); var rowSelectedTrigger = new Trigger { Property = System.Windows.Controls.DataGridRow.IsSelectedProperty, Value = true }; - rowSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 63)))); + rowSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, highlightDark)); + rowSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, highlightDarkText)); dgRowStyle.Triggers.Add(rowSelectedTrigger); app.Resources[typeof(System.Windows.Controls.DataGridRow)] = dgRowStyle; @@ -750,31 +614,127 @@ private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) var tabControlStyle = new Style(typeof(System.Windows.Controls.TabControl)); tabControlStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkWindow)); tabControlStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); + tabControlStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, darkControl)); app.Resources[typeof(System.Windows.Controls.TabControl)] = tabControlStyle; + // TabItem style - for individual tab headers + var tabItemStyle = new Style(typeof(System.Windows.Controls.TabItem)); + tabItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkControl)); + tabItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); + tabItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, darkControl)); + tabItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.PaddingProperty, new Thickness(8, 4, 8, 4))); + + var tabSelectedTrigger = new Trigger { Property = System.Windows.Controls.TabItem.IsSelectedProperty, Value = true }; + tabSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkWindow)); + tabSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, accent)); + tabItemStyle.Triggers.Add(tabSelectedTrigger); + + var tabMouseOverTrigger = new Trigger { Property = System.Windows.Controls.TabItem.IsMouseOverProperty, Value = true }; + tabMouseOverTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(112, 112, 174)))); + tabMouseOverTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0, 0, 0)))); + tabItemStyle.Triggers.Add(tabMouseOverTrigger); + + app.Resources[typeof(System.Windows.Controls.TabItem)] = tabItemStyle; + var groupBoxStyle = new Style(typeof(System.Windows.Controls.GroupBox)); groupBoxStyle.Setters.Add(new Setter(System.Windows.Window.BackgroundProperty, darkWindow)); groupBoxStyle.Setters.Add(new Setter(System.Windows.Window.ForegroundProperty, lightText)); app.Resources[typeof(System.Windows.Controls.GroupBox)] = groupBoxStyle; + // ComboBox requires a custom template to properly theme all internal parts + var comboBoxTemplate = new System.Windows.Controls.ControlTemplate(typeof(System.Windows.Controls.ComboBox)); + var templateContent = @" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "; + var comboBoxStyle = new Style(typeof(System.Windows.Controls.ComboBox)); comboBoxStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkControl)); comboBoxStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); + comboBoxStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, accent)); + comboBoxStyle.Setters.Add(new Setter(System.Windows.Controls.Control.TemplateProperty, + (System.Windows.Controls.ControlTemplate)System.Windows.Markup.XamlReader.Parse(templateContent))); app.Resources[typeof(System.Windows.Controls.ComboBox)] = comboBoxStyle; // Ensure ComboBox items and selected text use the dark theme when the control is not focused. var comboBoxItemStyle = new Style(typeof(System.Windows.Controls.ComboBoxItem)); - comboBoxItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkWindow)); + comboBoxItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkControl)); comboBoxItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); comboBoxItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, System.Windows.Media.Brushes.Transparent)); + comboBoxItemStyle.Setters.Add(new Setter(System.Windows.Controls.Control.PaddingProperty, new Thickness(4))); var comboSelectedTrigger = new Trigger { Property = System.Windows.Controls.ComboBoxItem.IsSelectedProperty, Value = true }; - comboSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkWindow)); + comboSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 63)))); comboSelectedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); - comboBoxItemStyle.Triggers.Add(comboSelectedTrigger); + + var comboHighlightedTrigger = new Trigger { Property = System.Windows.Controls.ComboBoxItem.IsHighlightedProperty, Value = true }; + comboHighlightedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(50, 50, 53)))); + comboHighlightedTrigger.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); + comboBoxItemStyle.Triggers.Add(comboHighlightedTrigger); + app.Resources[typeof(System.Windows.Controls.ComboBoxItem)] = comboBoxItemStyle; + // Style for the ComboBox toggle button + var toggleButtonStyle = new Style(typeof(System.Windows.Controls.Primitives.ToggleButton)); + toggleButtonStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkControl)); + toggleButtonStyle.Setters.Add(new Setter(System.Windows.Controls.Control.ForegroundProperty, lightText)); + toggleButtonStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BorderBrushProperty, accent)); + app.Resources[typeof(System.Windows.Controls.Primitives.ToggleButton)] = toggleButtonStyle; + + // Style for ComboBox Popup background + var popupStyle = new Style(typeof(System.Windows.Controls.Primitives.Popup)); + popupStyle.Setters.Add(new Setter(System.Windows.Controls.Control.BackgroundProperty, darkWindow)); + app.Resources[typeof(System.Windows.Controls.Primitives.Popup)] = popupStyle; + var windowStyle = new Style(typeof(System.Windows.Window)); windowStyle.Setters.Add(new Setter(System.Windows.Window.BackgroundProperty, darkWindow)); windowStyle.Setters.Add(new Setter(System.Windows.Window.ForegroundProperty, lightText)); @@ -801,4 +761,46 @@ public static string GetInformationalVersion() ?.InformationalVersion ?? GetAssemblyVersion(); } +} + +public class FileWatchItem +{ + public long StartingSize { get; set; } + public int ElapsedTime { get; set; } + public FileWatchItem(long startingSize) + { + StartingSize = startingSize; + ElapsedTime = 0; + } +} + +public class FileTailStatus +{ + public string FilePath { get; } + public int LinesProcessed { get; private set; } + public DateTime? LastLineProcessedTime { get; private set; } + public DateTime StartTime { get; } + public CancellationTokenSource CancellationSource { get; } + + public FileTailStatus(string filePath) + { + FilePath = filePath; + LinesProcessed = 0; + LastLineProcessedTime = null; + StartTime = DateTime.Now; + CancellationSource = new CancellationTokenSource(); + } + + public void IncrementLinesProcessed() + { + LinesProcessed++; + LastLineProcessedTime = DateTime.Now; + } + + public void RequestCancellation() + { + CancellationSource.Cancel(); + } + + public bool IsCancellationRequested => CancellationSource.Token.IsCancellationRequested; } \ No newline at end of file diff --git a/src/Resources/tailgrab.ico b/src/Resources/tailgrab.ico index cdecb0a..6ca27f6 100644 Binary files a/src/Resources/tailgrab.ico and b/src/Resources/tailgrab.ico differ diff --git a/src/Resources/tailgrab.png b/src/Resources/tailgrab.png deleted file mode 100644 index 6b22cb4..0000000 --- a/src/Resources/tailgrab.png +++ /dev/null @@ -1 +0,0 @@ -iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII= \ No newline at end of file diff --git a/src/Resources/tailgrab_large.ico b/src/Resources/tailgrab_large.ico deleted file mode 100644 index fbf100a..0000000 Binary files a/src/Resources/tailgrab_large.ico and /dev/null differ diff --git a/src/ServiceRegistry.cs b/src/ServiceRegistry.cs index 020fcc2..1098eee 100644 --- a/src/ServiceRegistry.cs +++ b/src/ServiceRegistry.cs @@ -1,9 +1,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NLog; +using System.IO; using Tailgrab.AvatarManagement; using Tailgrab.Clients.Ollama; using Tailgrab.Clients.VRChat; +using Tailgrab.Common; +using Tailgrab.Configuration; using Tailgrab.Models; using Tailgrab.PlayerManagement; @@ -25,28 +28,57 @@ public ServiceRegistry() public async void StartAllServices() { - logger.Info("Starting all services..."); + try + { + logger.Info("Starting all services..."); - logger.Info("Starting dbContext..."); + logger.Info("Starting dbContext..."); - services.AddDbContext(options => options.UseSqlite("Data Source=./data/avatars.sqlite")); - IServiceProvider serviceProvider = services.BuildServiceProvider(); + // Define directory: %LOCALAPPDATA%\YourAppName + string dbFolder = Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "data"); + string dbPath = Path.Combine(dbFolder, CommonConst.APPLICATION_LOCAL_DATABASE); + + services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}")); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + + dbContext = serviceProvider.GetService(); + if (dbContext == null) + { + System.Windows.MessageBox.Show("Failed to initialize database context. Please check the application logs for details.", "Initialization Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + throw new InvalidOperationException("TailgrabDBContext could not be resolved from the service provider."); + } + dbContext.Database.EnsureCreated(); + + logger.Info("Starting VR Chat API Client..."); + await vrcAPIClient.Initialize(); + + logger.Info("Starting OLLama API Client..."); + ollamaAPIClient = new OllamaClient(this); - dbContext = serviceProvider.GetService(); + logger.Info("Starting Avatar Manager..."); + avatarManager = new AvatarManagementService(this); - logger.Info("Starting VR Chat API Client..."); - await vrcAPIClient.Initialize(); + logger.Info("Starting Player Manager..."); + playerManager = new PlayerManager(this); - logger.Info("Starting OLLama API Client..."); - ollamaAPIClient = new OllamaClient(this); + playerManager.SyncAvatarModerations(); - logger.Info("Starting Avatar Manager..."); - avatarManager = new AvatarManagementService(this); + logger.Info("Starting Avatar GIST Manager..."); + AvatarBosGistListManager avatarGistMgr = new AvatarBosGistListManager(avatarManager); + _ = Task.Run(() => avatarGistMgr.ProcessAvatarGistList()); - logger.Info("Starting Player Manager..."); - playerManager = new PlayerManager(this); + logger.Info("Starting Group GIST Manager..."); + GroupBosGistListManager groupGistMgr = new GroupBosGistListManager(dbContext, playerManager); + _ = Task.Run(() => groupGistMgr.ProcessGroupGistList()); - logger.Info("All services started."); + + + logger.Info("All services started."); + } + catch (Exception ex) + { + logger.Error(ex); + } } public VRChatClient GetVRChatAPIClient() diff --git a/src/configuration/AvatarBosGistListManager.cs b/src/configuration/AvatarBosGistListManager.cs index 9a30e01..80042ce 100644 --- a/src/configuration/AvatarBosGistListManager.cs +++ b/src/configuration/AvatarBosGistListManager.cs @@ -3,7 +3,9 @@ using System.Net.Http; using System.Security.Cryptography; using System.Text; +using System.Text.RegularExpressions; using Tailgrab.AvatarManagement; +using Tailgrab.Common; using Tailgrab.Models; namespace Tailgrab.Configuration @@ -11,12 +13,12 @@ namespace Tailgrab.Configuration public class AvatarBosGistListManager { private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - private readonly ServiceRegistry _serviceRegistry; + private readonly AvatarManagementService avatarManager; private readonly HttpClient _httpClient; - public AvatarBosGistListManager(ServiceRegistry serviceRegistry) + public AvatarBosGistListManager(AvatarManagementService avatarManagement) { - _serviceRegistry = serviceRegistry ?? throw new ArgumentNullException(nameof(serviceRegistry)); + avatarManager = avatarManagement; _httpClient = new HttpClient(); } @@ -38,10 +40,10 @@ public async Task ProcessAvatarGistList() try { logger.Info($"Downloading GIST content from: {gistUrl}"); - + // Download the GIST content string gistContent = await DownloadGistContentAsync(gistUrl); - + if (string.IsNullOrEmpty(gistContent)) { logger.Warn("Downloaded GIST content is empty."); @@ -54,7 +56,7 @@ public async Task ProcessAvatarGistList() // Get the stored checksum from registry string? storedChecksum = GetStoredChecksum(); - + // Compare checksums if (storedChecksum != null && storedChecksum.Equals(currentChecksum, StringComparison.OrdinalIgnoreCase)) { @@ -66,7 +68,7 @@ public async Task ProcessAvatarGistList() // Process the file line by line int processedCount = await ProcessAvatarIdsAsync(gistContent); - + logger.Info($"Processed {processedCount} avatar IDs from GIST."); // Save the new checksum to registry @@ -100,7 +102,7 @@ private string CalculateMD5Checksum(string content) { byte[] inputBytes = Encoding.UTF8.GetBytes(content); byte[] hashBytes = md5.ComputeHash(inputBytes); - + StringBuilder sb = new StringBuilder(); foreach (byte b in hashBytes) { @@ -114,7 +116,7 @@ private string CalculateMD5Checksum(string content) { try { - using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.Common.ConfigRegistryPath)) + using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.CommonConst.ConfigRegistryPath)) { if (key == null) { @@ -122,7 +124,7 @@ private string CalculateMD5Checksum(string content) return null; } - string? value = key.GetValue(Common.Common.Registry_Avatar_Checksum) as string; + string? value = key.GetValue(Common.CommonConst.Registry_Avatar_Checksum) as string; if (string.IsNullOrEmpty(value)) { logger.Debug("No checksum stored in registry."); @@ -143,7 +145,7 @@ private string CalculateMD5Checksum(string content) { try { - using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.Common.ConfigRegistryPath)) + using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.CommonConst.ConfigRegistryPath)) { if (key == null) { @@ -151,7 +153,7 @@ private string CalculateMD5Checksum(string content) return null; } - string? value = key.GetValue(Common.Common.Registry_Avatar_Gist) as string; + string? value = key.GetValue(Common.CommonConst.Registry_Avatar_Gist) as string; if (string.IsNullOrEmpty(value)) { logger.Debug("No Avatar GIST Uri stored in registry."); @@ -172,9 +174,9 @@ private void SaveChecksum(string checksum) { try { - using (RegistryKey key = Registry.CurrentUser.CreateSubKey(Common.Common.ConfigRegistryPath)) + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(Common.CommonConst.ConfigRegistryPath)) { - key.SetValue(Common.Common.Registry_Avatar_Checksum, checksum, RegistryValueKind.String); + key.SetValue(Common.CommonConst.Registry_Avatar_Checksum, checksum, RegistryValueKind.String); } } catch (Exception ex) @@ -186,8 +188,6 @@ private void SaveChecksum(string checksum) private async Task ProcessAvatarIdsAsync(string gistContent) { int processedCount = 0; - TailgrabDBContext dbContext = _serviceRegistry.GetDBContext(); - AvatarManagementService avatarService = _serviceRegistry.GetAvatarManager(); using (System.IO.StringReader reader = new System.IO.StringReader(gistContent)) { @@ -204,15 +204,18 @@ private async Task ProcessAvatarIdsAsync(string gistContent) } // Split by whitespace or comma to get the first column - string[] columns = line.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (columns.Length == 0) + string pattern = @",(?=(?:[^""]*""[^""]*"")*[^""]*$)"; + string[] columns = Regex.Split(line, pattern); //.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (columns.Length < 3) { + logger.Warn($"Line {lineNumber}: Expected at least 3 columns (AvatarId, AvatarName, AlertType), but got {columns.Length}. Skipping line."); continue; } string avatarId = columns[0].Trim().Trim('"'); - logger.Info(avatarId); + string avatarName = columns[1].Trim().Trim('"'); + string avatarAlert = columns[2].Trim().Trim('"'); if (string.IsNullOrWhiteSpace(avatarId)) { @@ -220,55 +223,26 @@ private async Task ProcessAvatarIdsAsync(string gistContent) continue; } - try + // Convert the alert type string to the AlertTypeEnum, defaulting to None if parsing fails + AlertTypeEnum alertType = AlertTypeEnum.None; + if (Enum.TryParse(avatarAlert, out alertType)) { - // Fetch the AvatarInfo record - AvatarInfo? avatarInfo = await dbContext.AvatarInfos.FindAsync(avatarId); - AvatarManagementService.FetchUpdateAvatarData(_serviceRegistry, dbContext, avatarId, avatarInfo); - avatarInfo = await dbContext.AvatarInfos.FindAsync(avatarId); - - if (avatarInfo == null) - { - logger.Debug($"Line {lineNumber}: Avatar ID '{avatarId}' not found in database, skipping."); - continue; - } - - // Set IsBOS to true - if (!avatarInfo.IsBos) - { - avatarInfo.IsBos = true; - avatarInfo.UpdatedAt = DateTime.UtcNow; - dbContext.AvatarInfos.Update(avatarInfo); - processedCount++; - logger.Debug($"Line {lineNumber}: Set IsBOS=true for Avatar ID '{avatarId}'"); - } - else - { - logger.Debug($"Line {lineNumber}: Avatar ID '{avatarId}' already has IsBOS=true, skipping."); - } + // Declared and Defaulted above } - catch (Exception ex) + else { - logger.Error(ex, $"Line {lineNumber}: Error processing avatar ID '{avatarId}'"); + logger.Warn($"Line {lineNumber}: Invalid AlertType '{avatarAlert}' for Avatar ID '{avatarId}', defaulting to None."); } - // Throttle processing to avoid overwhelming the API - await Task.Delay(1000); + AvatarInfo? info = avatarManager.GetAvatarById(avatarId); + if (info == null || info.AlertType == AlertTypeEnum.None) { + QueuedAvatarWatch watchItem = new QueuedAvatarWatch(1, avatarId, alertType, lineNumber); + avatarManager.EnqueueWatchAvatarForCheck(watchItem); + } + processedCount++; } } - // Save all changes to the database - try - { - await dbContext.SaveChangesAsync(); - logger.Info($"Successfully saved {processedCount} changes to the database."); - } - catch (Exception ex) - { - logger.Error(ex, "Failed to save changes to the database."); - throw; - } - return processedCount; } } diff --git a/src/configuration/ConfigurationManager.cs b/src/configuration/ConfigurationManager.cs index 68233bb..0ef07c1 100644 --- a/src/configuration/ConfigurationManager.cs +++ b/src/configuration/ConfigurationManager.cs @@ -27,23 +27,15 @@ public ConfigurationManager(ServiceRegistry serviceRegistry) public string GetConfigFilePath() { - string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - string configDirectory = Path.Combine(userProfile, ".tailgrab"); - if (!Directory.Exists(configDirectory)) - { - Directory.CreateDirectory(configDirectory); - } - - return Path.Combine(configDirectory, "config.json"); + return Path.Combine(CommonConst.APPLICATION_LOCAL_DATA_PATH, "config.json"); } - public List LoadConfig(string? configFilePath = null) + public List LoadConfig() { logger.Debug("** Loading Configuration file"); // Resolve path: prefer explicit, then local repo config.json, then user config path - string path = configFilePath - ?? (File.Exists("config.json") ? Path.GetFullPath("config.json") : GetConfigFilePath()); + string path = (File.Exists("config.json") ? Path.GetFullPath("config.json") : GetConfigFilePath()); if (!File.Exists(path)) { diff --git a/src/configuration/GroupBosGistListManager.cs b/src/configuration/GroupBosGistListManager.cs index 81135f1..c5e09bf 100644 --- a/src/configuration/GroupBosGistListManager.cs +++ b/src/configuration/GroupBosGistListManager.cs @@ -3,20 +3,24 @@ using System.Net.Http; using System.Security.Cryptography; using System.Text; -using Tailgrab.AvatarManagement; +using System.Text.RegularExpressions; +using Tailgrab.Common; using Tailgrab.Models; +using Tailgrab.PlayerManagement; namespace Tailgrab.Configuration { public class GroupBosGistListManager { private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - private readonly ServiceRegistry _serviceRegistry; private readonly HttpClient _httpClient; + private readonly TailgrabDBContext dbContext; + private readonly PlayerManager playerManager; - public GroupBosGistListManager(ServiceRegistry serviceRegistry) + public GroupBosGistListManager(TailgrabDBContext tailgrabDBContext, PlayerManager management) { - _serviceRegistry = serviceRegistry ?? throw new ArgumentNullException(nameof(serviceRegistry)); + dbContext = tailgrabDBContext; + playerManager = management; _httpClient = new HttpClient(); } @@ -38,10 +42,10 @@ public async Task ProcessGroupGistList() try { logger.Info($"Downloading GIST content from: {gistUrl}"); - + // Download the GIST content string gistContent = await DownloadGistContentAsync(gistUrl); - + if (string.IsNullOrEmpty(gistContent)) { logger.Warn("Downloaded GIST content is empty."); @@ -54,7 +58,7 @@ public async Task ProcessGroupGistList() // Get the stored checksum from registry string? storedChecksum = GetStoredChecksum(); - + // Compare checksums if (storedChecksum != null && storedChecksum.Equals(currentChecksum, StringComparison.OrdinalIgnoreCase)) { @@ -66,7 +70,7 @@ public async Task ProcessGroupGistList() // Process the file line by line int processedCount = await ProcessGroupIdsAsync(gistContent); - + logger.Info($"Processed {processedCount} Group IDs from GIST."); // Save the new checksum to registry @@ -100,7 +104,7 @@ private string CalculateMD5Checksum(string content) { byte[] inputBytes = Encoding.UTF8.GetBytes(content); byte[] hashBytes = md5.ComputeHash(inputBytes); - + StringBuilder sb = new StringBuilder(); foreach (byte b in hashBytes) { @@ -114,7 +118,7 @@ private string CalculateMD5Checksum(string content) { try { - using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.Common.ConfigRegistryPath)) + using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.CommonConst.ConfigRegistryPath)) { if (key == null) { @@ -122,7 +126,7 @@ private string CalculateMD5Checksum(string content) return null; } - string? value = key.GetValue(Common.Common.Registry_Group_Checksum) as string; + string? value = key.GetValue(Common.CommonConst.Registry_Group_Checksum) as string; if (string.IsNullOrEmpty(value)) { logger.Debug("No checksum stored in registry."); @@ -143,7 +147,7 @@ private string CalculateMD5Checksum(string content) { try { - using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.Common.ConfigRegistryPath)) + using (RegistryKey? key = Registry.CurrentUser.OpenSubKey(Common.CommonConst.ConfigRegistryPath)) { if (key == null) { @@ -151,7 +155,7 @@ private string CalculateMD5Checksum(string content) return null; } - string? value = key.GetValue(Common.Common.Registry_Group_Gist) as string; + string? value = key.GetValue(Common.CommonConst.Registry_Group_Gist) as string; if (string.IsNullOrEmpty(value)) { logger.Debug("No Avatar GIST Uri stored in registry."); @@ -172,9 +176,9 @@ private void SaveChecksum(string checksum) { try { - using (RegistryKey key = Registry.CurrentUser.CreateSubKey(Common.Common.ConfigRegistryPath)) + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(Common.CommonConst.ConfigRegistryPath)) { - key.SetValue(Common.Common.Registry_Group_Checksum, checksum, RegistryValueKind.String); + key.SetValue(Common.CommonConst.Registry_Group_Checksum, checksum, RegistryValueKind.String); } } catch (Exception ex) @@ -186,8 +190,6 @@ private void SaveChecksum(string checksum) private async Task ProcessGroupIdsAsync(string gistContent) { int processedCount = 0; - TailgrabDBContext dbContext = _serviceRegistry.GetDBContext(); - AvatarManagementService avatarService = _serviceRegistry.GetAvatarManager(); using (System.IO.StringReader reader = new System.IO.StringReader(gistContent)) { @@ -204,15 +206,20 @@ private async Task ProcessGroupIdsAsync(string gistContent) } // Split by whitespace or comma to get the first column - string[] columns = line.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (columns.Length == 0) + //string[] columns = line.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries); + string pattern = @",(?=(?:[^""]*""[^""]*"")*[^""]*$)"; + string[] columns = Regex.Split(line, pattern); //.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries); + + + if (columns.Length < 3) { + logger.Warn($"Line {lineNumber}: Expected at least 3 columns (GroupId, GroupName, AlertType), but got {columns.Length}. Skipping line."); continue; } string groupId = columns[0].Trim().Trim('"'); - logger.Info(groupId); + string groupName = columns[1].Trim().Trim('"'); + string groupAlert = columns[2].Trim().Trim('"'); if (string.IsNullOrWhiteSpace(groupId)) { @@ -220,10 +227,21 @@ private async Task ProcessGroupIdsAsync(string gistContent) continue; } + // Convert the alert type string to the AlertTypeEnum, defaulting to None if parsing fails + AlertTypeEnum alertType = AlertTypeEnum.None; + if (Enum.TryParse(groupAlert, out alertType)) + { + // Declared and Defaulted above + } + else + { + logger.Warn($"Line {lineNumber}: Invalid AlertType '{groupAlert}' for Group ID '{groupId}', defaulting to None."); + } + try { // Fetch the GroupInfo record - GroupInfo? groupInfo = _serviceRegistry.GetPlayerManager().AddUpdateGroupFromVRC( groupId ); + GroupInfo? groupInfo = playerManager.AddUpdateGroupFromVRC(groupId); if (groupInfo == null) { logger.Debug($"Line {lineNumber}: Group ID '{groupId}' not found in database, skipping."); @@ -231,17 +249,17 @@ private async Task ProcessGroupIdsAsync(string gistContent) } // Set IsBOS to true - if (!groupInfo.IsBos) + if (groupInfo.AlertType == AlertTypeEnum.None) { - groupInfo.IsBos = true; + groupInfo.AlertType = alertType; groupInfo.UpdatedAt = DateTime.UtcNow; dbContext.GroupInfos.Update(groupInfo); processedCount++; - logger.Debug($"Line {lineNumber}: Set IsBOS=true for Group ID '{groupId}'"); + logger.Debug($"Line {lineNumber}: Set AlertType for Group ID '{groupId}'"); } else { - logger.Debug($"Line {lineNumber}: Group ID '{groupId}' already has IsBOS=true, skipping."); + logger.Debug($"Line {lineNumber}: Group ID '{groupId}' already has AlertType, skipping."); } } catch (Exception ex) diff --git a/src/sounds/A380_Retard.wav b/src/sounds/A380_Retard.wav new file mode 100644 index 0000000..f3a1b44 Binary files /dev/null and b/src/sounds/A380_Retard.wav differ diff --git a/src/sounds/Bad_Group_Bad_Group.wav b/src/sounds/Bad_Group_Bad_Group.wav new file mode 100644 index 0000000..586a868 Binary files /dev/null and b/src/sounds/Bad_Group_Bad_Group.wav differ diff --git a/src/sounds/Crasher_Crasher_Ban_Now.wav b/src/sounds/Crasher_Crasher_Ban_Now.wav new file mode 100644 index 0000000..1d7192b Binary files /dev/null and b/src/sounds/Crasher_Crasher_Ban_Now.wav differ diff --git a/src/sounds/ICQ_Uh_Oh.mp3 b/src/sounds/ICQ_Uh_Oh.mp3 deleted file mode 100644 index bc7dfa6..0000000 Binary files a/src/sounds/ICQ_Uh_Oh.mp3 and /dev/null differ diff --git a/src/sounds/ICQ_Uh_Oh.wav b/src/sounds/ICQ_Uh_Oh.wav new file mode 100644 index 0000000..b62efbc Binary files /dev/null and b/src/sounds/ICQ_Uh_Oh.wav differ diff --git a/src/sounds/Police_Double_Chirping.mp3 b/src/sounds/Police_Double_Chirping.mp3 deleted file mode 100644 index 1bea51b..0000000 Binary files a/src/sounds/Police_Double_Chirping.mp3 and /dev/null differ diff --git a/src/sounds/Police_Double_Chirping.wav b/src/sounds/Police_Double_Chirping.wav new file mode 100644 index 0000000..bd80f86 Binary files /dev/null and b/src/sounds/Police_Double_Chirping.wav differ diff --git a/src/sounds/TCas_CrasherBanNow.wav b/src/sounds/TCas_CrasherBanNow.wav new file mode 100644 index 0000000..c49c2a7 Binary files /dev/null and b/src/sounds/TCas_CrasherBanNow.wav differ diff --git a/src/sounds/retard.wav b/src/sounds/retard.wav new file mode 100644 index 0000000..dd2a5bb Binary files /dev/null and b/src/sounds/retard.wav differ diff --git a/tailgrab.csproj b/tailgrab.csproj index d003c4f..9c01fc4 100644 --- a/tailgrab.csproj +++ b/tailgrab.csproj @@ -3,14 +3,29 @@ WinExe net10.0-windows + true + true + true + win-x64 enable true true enable - 1.0.9.1722 - 1.0.9.1722 - 1.0.9.1722 - src\Resources\tailgrab_large.ico + src\Resources\tailgrab.ico + + + $(MSBuildProjectDirectory)\BuildNumber.txt + $([System.IO.File]::ReadAllText('$(BuildNumberFile)').Trim()) + + + 1.1.0.$(BuildNumber) + 1.1.0.$(BuildNumber) + 1.1.0.$(BuildNumber) + https://github.com/jlong23/Tailgrab + README.md + https://github.com/jlong23/Tailgrab + git + AnyCPU;x64 @@ -41,20 +56,6 @@ - - - - - - - - - - - - - - @@ -111,4 +112,36 @@ + + + PreserverNewest + PreserveNewest + %(Filename)%(Extension) + + + + + + True + \ + + + + + + + $([System.IO.File]::ReadAllText('$(BuildNumberFile)').Trim()) + $([MSBuild]::Add($(CurrentBuildNumber), 1)) + + + + + + $(NewBuildNumber) + 1.1.0.$(BuildNumber) + 1.1.0.$(BuildNumber) + 1.1.0.$(BuildNumber) + + + \ No newline at end of file diff --git a/tailgrab.sln b/tailgrab.sln index 89c373c..8f9fae7 100644 --- a/tailgrab.sln +++ b/tailgrab.sln @@ -4,16 +4,30 @@ VisualStudioVersion = 18.2.11430.68 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tailgrab", "tailgrab.csproj", "{910B279B-72B3-3CFC-4BFE-5A237E51A72C}" EndProject +Project("{54435603-DBB4-11D2-8724-00A0C9A8B90C}") = "Tailgrab Install", "..\Tailgrab Install\Tailgrab Install.vdproj", "{793BC8C4-7D9E-1942-44B6-88CE35D69FDB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Debug|Any CPU.ActiveCfg = Release|Any CPU {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Debug|Any CPU.Build.0 = Release|Any CPU + {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Debug|x64.ActiveCfg = Debug|x64 + {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Debug|x64.Build.0 = Debug|x64 {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Release|Any CPU.ActiveCfg = Release|Any CPU {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Release|Any CPU.Build.0 = Release|Any CPU + {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Release|x64.ActiveCfg = Release|Any CPU + {910B279B-72B3-3CFC-4BFE-5A237E51A72C}.Release|x64.Build.0 = Release|Any CPU + {793BC8C4-7D9E-1942-44B6-88CE35D69FDB}.Debug|Any CPU.ActiveCfg = Debug + {793BC8C4-7D9E-1942-44B6-88CE35D69FDB}.Debug|x64.ActiveCfg = Debug + {793BC8C4-7D9E-1942-44B6-88CE35D69FDB}.Debug|x64.Build.0 = Debug + {793BC8C4-7D9E-1942-44B6-88CE35D69FDB}.Release|Any CPU.ActiveCfg = Release + {793BC8C4-7D9E-1942-44B6-88CE35D69FDB}.Release|x64.ActiveCfg = Release + {793BC8C4-7D9E-1942-44B6-88CE35D69FDB}.Release|x64.Build.0 = Release EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE