Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 38 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,62 @@
# TailGrab
VRChat Log Parser and Automation tool to help moderators manage trouble makers in VRChat since VRChat will not take moderation seriously.
VRChat Log Parser and Automation tool to help moderators manage trouble makers in VRChat since VRChat Management Team is not taking moderation seriously; ever.

# Capabilities

The core concept of the TailGrab was to create a Windows friendly ```grep``` of VR Chat log events that would allow a group moderation team to review, get insights of bad actors and with the action framework to perform a scripted reaction to a VR Chat game log event.

EG:
A ```Vote To Kick``` is received from a patreon, the action sequence could:
- Send a OSC Avatar Parameter(s) that change the avatar's ear position
- Delay for a quarter of a second
- Send a keystroke to your soundboard application
- Send a keystroke to OBS to start recording

# Usage
Open a Powershell or Command Line prompt in your windows host, change directory to where ```tailgrab``` has been extracted to and start it with:
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```

Or if you have moved where the VR Chat ```output_log_*.txt``` are located; then:

```.\tailgrab.exe {full path to VR Chat logs ending with a \}```

## VRChat Source Log Files

By default TailGrab will look for VRChat log files in the default location of:

```YourUserHome\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\```

## 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.

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```

# Configuration

## Environment Variables
The TailGrab application will look for the following environment variables to connect to your VRChat API and OLLama AI services.
## VR Chat and OLLama API Credentials

Tailgrab uses VR Chat's public API to get information about avatars for the BlackListed Database (SQLite Local DB) and to get user profile infoformation for Profile Evaluation with the AI services.
OLLama Cloud AI services are used to evaluate user profiles for potential bad actors based on your custom prompt criteria. The OLLama API is called only once for a MD5 checksummed profile to reduce API usage and cost.

The TailGrab application will look for the following credentials to connect to your VRChat API and OLLama AI services from the Windows Registry in a encyrpted format. On the first Run you may receive a Popup Message to set the values on the Config -> Secrets Tab and restart the application to get the services running properly.

|Environment Variable | Definition |
|--------|--------|
| VRCHAT_USERNAME | Your VRChat User Name (What you use to sign into the VR Chat Web Page). |
| VRCHAT_PASSWORD | Your VRChat Password (What you use to sign into the VR Chat Web Page). |
| VRCHAT_TWO_FACTOR_SECRET | Your VRChat Two Factor Authentication Secret (If you have 2FA enabled on your account; **RECOMENDED** ). See https://docs.vrchat.com/docs/using-2fa for more information. |
| OLLAMA_API_KEY | Your OLLama API Key to access your AI services. See https://ollama.com/docs/api for more information. |
## Getting your VR Chat 2 Factor Authentication key

I certainly hope you are using LastPass Authenticator or Google Authenticator to manage your 2FA codes for VRChat. If you are not, please stop reading this and go set that up now to protect your Online Accounts.

On Windows 11 you can set these environment variables by searching for "Environment Variables" in the Start Menu and selecting "Edit the User environment variables". Then click on the "Environment Variables" button and add the variables under "User variables" (Suggested) or "System variables".
If launching from a Powershell or Command Line prompt, you will need to close the window for the values to be set for that session
On LastPass Authenticator for the your VR Chat Entry, you can use the right Hamburger menu icon to get a dialog of options, one of which is to 'Edit Account', select that and you will see the 'Secret Key' field, copy the 'Secret Key' value to your clipboard and paste to something you can transfer to your PC (Or tediously type it in from the screen).

## "Config.json" File

The confiuration for TailGrab uses a JSON formated payload of the base attribute "lineHandlers" that contains a array of LineHandler Objects, Those may have a attribute of "actions" that contain an array of Action Objects.
The confiuration for TailGrab uses a JSON formated payload of the base attribute "lineHandlers" that contains a array of LineHandler Objects, Those may have a attribute of "actions" that contain an array of Action Objects. This configuration is loaded on application start.

## LineHandler Definition

Expand Down Expand Up @@ -161,25 +186,3 @@ To specify keys combined with any combination of the SHIFT, CTRL, and ALT keys,
To specify that any combination of SHIFT, CTRL, and ALT should be held down while several other keys are pressed, enclose the code for those keys in parentheses. For example, to specify to hold down SHIFT while E and C are pressed, use ```"+(EC)"```. To specify to hold down SHIFT while E is pressed, followed by C without SHIFT, use ```"+EC"```.

To specify repeating keys, use the form ```{key number}```. You must put a space between key and number. For example, ```{LEFT 42}``` means press the LEFT ARROW key 42 times; ```{h 10}``` means press H 10 times.



# Capabilities

The core concept of the TailGrab was to create a Windows friendly ```grep``` of VR Chat log events that would allow a group moderation team to review, get insights of bad actors and with the action framework to perform a scripted reaction to a log event.

EG:
A ```Vote To Kick``` is received from a patreon, the action sequence could:
- Send a OSC Avatar Parameter(s) that change the avatar's ear position
- Delay for a quarter of a second
- Send a keystroke to your soundboard application
- Send a keystroke to OBS to start recording


## POC Version
- Parse VRChat log files
- World ```Furry Hideout``` will record User Pen Interaction
- Record User's avatar usage while in the instance
- Record User's moderation while in the instance (Warn and final Kick)
- Partial work with OSC Triggered events to send to your avatar
- Partial work with Keystroke events sent to a application of your choice
127 changes: 73 additions & 54 deletions src/Clients/Ollama/Ollama.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using ConcurrentPriorityQueue.Core;
using Microsoft.EntityFrameworkCore;
using NLog;
using OllamaSharp;
using OllamaSharp.Models;
using System.Net.Http;
using System.Text.RegularExpressions;
using Tailgrab.Common;
using Tailgrab.Config;
using Tailgrab.Models;
using Tailgrab.PlayerManagement;
using VRChat.API.Model;
using static System.Windows.Forms.VisualStyles.VisualStyleElement.StartPanel;

namespace Tailgrab.Clients.Ollama
{
Expand Down Expand Up @@ -77,15 +80,21 @@ public void CheckUserProfile(string userId)
}
public static async Task ProfileCheckTask(ConcurrentPriorityQueue<IHavePriority<int>, int> priorityQueue, Dictionary<string, string> processData, ServiceRegistry serviceRegistry )
{
string? ollamaCloudKey = Environment.GetEnvironmentVariable("OLLAMA_CLOUD_KEY");
string? ollamaCloudKey = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Key);

if (ollamaCloudKey is null)
throw new InvalidOperationException("OLLAMA_CLOUD_KEY environment variable is not set.");
{
System.Windows.MessageBox.Show("Ollama 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 ollamaEndpoint = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Endpoint) ?? Tailgrab.Common.Common.Default_Ollama_API_Endpoint;
HttpClient client = new HttpClient();
client.BaseAddress = new Uri("https://ollama.com");
client.BaseAddress = new Uri(ollamaEndpoint);
client.DefaultRequestHeaders.Add("Authorization", "Bearer " + ollamaCloudKey);
OllamaApiClient ollamaApi = new OllamaApiClient(client);
ollamaApi.SelectedModel = "gemma3:27b";
OllamaApiClient? ollamaApi = new OllamaApiClient(client);
string ollamaModel = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Model) ?? Tailgrab.Common.Common.Default_Ollama_API_Model;
ollamaApi.SelectedModel = ollamaModel;

OllamaClient.logger.Info($"OLlama Queue Running");
while (true)
Expand All @@ -104,62 +113,27 @@ public static async Task ProfileCheckTask(ConcurrentPriorityQueue<IHavePriority<
string fullProfile = $"DisplayName: {profile.DisplayName}\nStatusDesc: {profile.StatusDescription}\nPronowns: {profile.Pronouns}\nProfileBio: {profile.Bio}\n";
item.UserBio = fullProfile;

logger.Debug($"Processing AI Evaluation Queued item for userId: {item.UserId}");
// Process the dequeued item
if (!string.IsNullOrEmpty(item.MD5Hash))
{
// Only when the Item has a valid hash
// Check if already evaluated
ProfileEvaluation? evaluated = serviceRegistry.GetDBContext().ProfileEvaluations.Find(item.MD5Hash);
if (evaluated == null)
{
GetEvaluationFromCloud(ollamaApi, serviceRegistry, item);
}
else
{
GetEvaluationFromStore(serviceRegistry, evaluated, item);
}
}
GetUserGroupInformation(serviceRegistry, dBContext, userGroups, item);

bool isSuspectGroup = false;
foreach ( LimitedUserGroups group in userGroups)
if (ollamaApi != null)
{
GroupInfo? groupInfo = dBContext.GroupInfos.Find(group.GroupId);
if (groupInfo == null)
logger.Debug($"Processing AI Evaluation Queued item for userId: {item.UserId}");
// Process the dequeued item
if (!string.IsNullOrEmpty(item.MD5Hash))
{
groupInfo = new GroupInfo
// Only when the Item has a valid hash
// Check if already evaluated
ProfileEvaluation? evaluated = serviceRegistry.GetDBContext().ProfileEvaluations.Find(item.MD5Hash);
if (evaluated == 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)
GetEvaluationFromCloud(ollamaApi, serviceRegistry, item);
}
else
{
isSuspectGroup = true;
GetEvaluationFromStore(serviceRegistry, evaluated, item);
}
}
}

if (isSuspectGroup)
{
Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId ?? string.Empty);
if (player != null)
{
player.IsGroupWatch = true;
serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player);
}
}
}
catch (Exception ex)
{
Expand All @@ -181,12 +155,57 @@ public static async Task ProfileCheckTask(ConcurrentPriorityQueue<IHavePriority<
}
}

private async static void GetUserGroupInformation(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, List<LimitedUserGroups> userGroups, QueuedProcess item )
{
logger.Debug($"Processing User Group subscription for userId: {item.UserId}");
bool isSuspectGroup = false;
foreach (LimitedUserGroups group in userGroups)
{
GroupInfo? groupInfo = dBContext.GroupInfos.Find(group.GroupId);
if (groupInfo == null)
{
groupInfo = new GroupInfo
{
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)
{
isSuspectGroup = true;
}
}
}

if (isSuspectGroup)
{
Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId ?? string.Empty);
if (player != null)
{
player.IsGroupWatch = true;
serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player);
}
}
}

private async static void GetEvaluationFromCloud(OllamaApiClient ollamaApi, ServiceRegistry serviceRegistry, QueuedProcess item)
{
string? ollamaPrompt = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_Ollama_API_Prompt);
GenerateRequest request = new GenerateRequest
{
Model = ollamaApi.SelectedModel,
Prompt = "From the following block of text, classify the contents into a single class; 'OK', 'Explicit Sexual', 'Harrassment & Bullying', 'Self Harm' or 'Other'. When replying, give a single line for the Classification and then a new line for the resoning: \n" + item.UserBio ?? string.Empty,
Prompt = ollamaPrompt ?? Tailgrab.Common.Common.Default_Ollama_API_Prompt + item.UserBio ?? string.Empty,
Stream = false
};

Expand Down
20 changes: 10 additions & 10 deletions src/Clients/VRChat/VRChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using OtpNet;
using System.IO;
using System.Net;
using Tailgrab.Config;
using VRChat.API.Client;
using VRChat.API.Model;

Expand All @@ -17,17 +18,16 @@ public class VRChatClient

public async void Initialize()
{
string? username = Environment.GetEnvironmentVariable("VRCHAT_USERNAME");
string? password = Environment.GetEnvironmentVariable("VRCHAT_PASSWORD");
string? twoFactorSecret = Environment.GetEnvironmentVariable("VRCHAT_TWO_FACTOR_SECRET");

if (username is null)
throw new InvalidOperationException("VRCHAT_USERNAME environment variable is not set.");
if (password is null)
throw new InvalidOperationException("VRCHAT_PASSWORD environment variable is not set.");
if (twoFactorSecret is null)
throw new InvalidOperationException("VRCHAT_TWO_FACTOR_SECRET environment variable is not set.");
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);

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
Expand Down
20 changes: 20 additions & 0 deletions src/Common/Common.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Tailgrab.Common
{
public static class Common
{
public const string ApplicationName = "Tailgrab";
public const string CompanyName = "DeviousFox";
public const string ConfigRegistryPath = "Software\\DeviousFox\\Tailgrab\\Config";
public const string Registry_VRChat_Web_UserName = "VRCHAT_USERNAME";
public const string Registry_VRChat_Web_Password = "VRCHAT_PASSWORD";
public const string Registry_VRChat_Web_2FactorKey = "VRCHAT_2FA";
public const string Registry_Ollama_API_Key = "OLLAMA_API_KEY";
public const string Registry_Ollama_API_Endpoint = "OLLAMA_API_ENDPOINT";
public const string Registry_Ollama_API_Prompt = "OLLAMA_API_PROMPT";
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;\n'OK', 'Explicit Sexual', 'Harrassment & Bullying', 'Self Harm' or 'Other'.\nWhen replying, give a single line for the Classification and then a new line for the resoning: \n";
public const string Default_Ollama_API_Endpoint = "https://ollama.com";
public const string Default_Ollama_API_Model = "gemma3:27b";

}
}
Loading
Loading