Skip to content
Open
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
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: 2
updates:
# Enable version updates for NuGet packages
- package-ecosystem: "nuget"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "nuget"

# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"
6 changes: 4 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ env:
jobs:
build:
runs-on: windows-latest
permissions:
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Setup .NET
uses: actions/setup-dotnet@v4
Expand Down Expand Up @@ -52,7 +54,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Setup .NET
uses: actions/setup-dotnet@v4
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,17 @@ A powerful desktop toolkit for VRChat group owners and moderators. Fast login, r
- Quick "Use" button to select user
- One-click invite sending

### 📅 Calendar Events
### � Inviter Hub (New!)
- **Instance Inviter**: Detect users in your current VRChat instance and invite them to your group.
- Filter by Trust Level (Visitor, New User, User, Known, Trusted).
- **18+ Only Filter**: Only show users with confirmed 18+ age verification.
- "Select All" and bulk invite capabilities.
- **Friend Inviter**: Quickly invite your online friends to your group.
- **Join Requests**: Monitor and process group join requests.
- Filter requests by 18+ status.
- Approve or block users directly.

### �📅 Calendar Events
- Create and manage group events
- **Event Options:**
- Title, description, category
Expand Down
6 changes: 6 additions & 0 deletions src/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@
<converters:DaysOfWeekStringConverter x:Key="DaysOfWeekStringConverter"/>
<converters:BoolToTestButtonConverter x:Key="BoolToTestButtonConverter"/>
<converters:ActionTypeToColorConverter x:Key="ActionTypeToColorConverter"/>
<converters:InitialsConverter x:Key="InitialsConverter"/>
<converters:InverseNullToVisibilityConverter x:Key="InverseNullToVisibilityConverter"/>
<converters:EqualityToVisibilityConverter x:Key="EqualityToVisibilityConverter"/>
<converters:InverseEqualityToVisibilityConverter x:Key="InverseEqualityToVisibilityConverter"/>
<converters:EmptyStringToVisibilityConverter x:Key="EmptyStringToVisibilityConverter"/>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter"/>

<!-- Custom Styles -->
<Style x:Key="CardStyle" TargetType="Border">
Expand Down
5 changes: 5 additions & 0 deletions src/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@ private void ConfigureServices(IServiceCollection services)
services.AddTransient<InviteToGroupViewModel>();
services.AddTransient<KillSwitchViewModel>();
services.AddTransient<AppSettingsViewModel>();
services.AddSingleton<IInstanceInviterService, InstanceInviterService>();
services.AddTransient<InstanceInviterViewModel>();
services.AddTransient<FriendInviterViewModel>();
services.AddTransient<InviterHubViewModel>();
services.AddTransient<GroupJoinRequestsViewModel>();

LoggingService.Debug("APP", "All services registered");
}
Expand Down
93 changes: 92 additions & 1 deletion src/Converters/Converters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,12 @@ public object Convert(object value, Type targetType, object parameter, CultureIn
{
if (value is int count)
{
return count == 0 ? Visibility.Visible : Visibility.Collapsed;
bool isInverse = parameter is string str && str.Equals("Inverse", StringComparison.OrdinalIgnoreCase);

if (count == 0)
return isInverse ? Visibility.Collapsed : Visibility.Visible;
else
return isInverse ? Visibility.Visible : Visibility.Collapsed;
}
return Visibility.Collapsed;
}
Expand Down Expand Up @@ -436,3 +441,89 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu
throw new NotImplementedException();
}
}

public class InitialsConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string name && !string.IsNullOrWhiteSpace(name))
{
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
return $"{parts[0][0]}{parts[1][0]}".ToUpper();
return name.Length >= 2 ? name.Substring(0, 2).ToUpper() : name.ToUpper();
}
return "??";
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

public class InverseNullToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value == null ? Visibility.Visible : Visibility.Collapsed;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

public class EqualityToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (parameter == null) return Visibility.Collapsed;

var paramValue = System.Convert.ToInt32(parameter);
var actualValue = value is int i ? i : 0;

return actualValue == paramValue ? Visibility.Visible : Visibility.Collapsed;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

public class InverseEqualityToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (parameter == null) return Visibility.Visible;

var paramValue = System.Convert.ToInt32(parameter);
var actualValue = value is int i ? i : 0;

return actualValue != paramValue ? Visibility.Visible : Visibility.Collapsed;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

public class EmptyStringToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string str)
{
return string.IsNullOrWhiteSpace(str) ? Visibility.Collapsed : Visibility.Visible;
}
return Visibility.Collapsed;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
10 changes: 10 additions & 0 deletions src/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public AppDbContext()
public DbSet<SecurityIncidentEntity> SecurityIncidents { get; set; } = null!;
public DbSet<MemberBackupEntity> MemberBackups { get; set; } = null!;
public DbSet<ModerationActionEntity> ModerationActions { get; set; } = null!;
public DbSet<InvitedUser> InvitedUsers { get; set; } = null!;

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
Expand Down Expand Up @@ -138,5 +139,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.HasIndex(e => new { e.GroupId, e.TargetUserId, e.ActionType });
entity.HasIndex(e => new { e.GroupId, e.TargetUserId, e.Reason, e.ActionTime });
});

// InvitedUser configuration
modelBuilder.Entity<InvitedUser>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.UserId);
entity.HasIndex(e => e.InvitedAt);
entity.HasIndex(e => new { e.UserId, e.WorldId, e.InstanceId });
});
}
}
24 changes: 24 additions & 0 deletions src/Data/Models/InvitedUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.ComponentModel.DataAnnotations;

namespace VRCGroupTools.Data.Models;

public class InvitedUser
{
[Key]
public int Id { get; set; }

public string UserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? ProfilePicUrl { get; set; }

public bool IsAgeVerified { get; set; }
public string? TrustLevel { get; set; }

public DateTime InvitedAt { get; set; } = DateTime.UtcNow;
public string? WorldId { get; set; }
public string? InstanceId { get; set; }

public bool InviteSuccessful { get; set; }
public string? ErrorMessage { get; set; }
}
70 changes: 58 additions & 12 deletions src/Services/AuditLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text.Json;
using System.Timers;
using System.Linq;
using VRCGroupTools.Data.Models;
using Timer = System.Timers.Timer;

namespace VRCGroupTools.Services;
Expand Down Expand Up @@ -35,6 +36,7 @@ public class AuditLogService : IAuditLogService, IDisposable
private readonly ICacheService _cacheService;
private readonly IDiscordWebhookService _discordService;
private readonly ISecurityMonitorService? _securityMonitor;
private readonly ISettingsService _settingsService;
private Timer? _pollingTimer;
private string? _currentGroupId;
private bool _isPolling;
Expand All @@ -48,11 +50,12 @@ public class AuditLogService : IAuditLogService, IDisposable
public bool IsPolling => _isPolling;
public int TotalLogCount => _totalLogCount;

public AuditLogService(IVRChatApiService apiService, ICacheService cacheService, IDiscordWebhookService discordService, ISecurityMonitorService? securityMonitor = null)
public AuditLogService(IVRChatApiService apiService, ICacheService cacheService, IDiscordWebhookService discordService, ISettingsService settingsService, ISecurityMonitorService? securityMonitor = null)
{
_apiService = apiService;
_cacheService = cacheService;
_discordService = discordService;
_settingsService = settingsService;
_securityMonitor = securityMonitor;
}

Expand Down Expand Up @@ -80,15 +83,13 @@ public async Task StartPollingAsync(string groupId)
StatusChanged?.Invoke(this, "No cached logs. Click 'Fetch History' to download.");
}

// Check for unsent Discord logs and send them
if (_discordService.IsConfigured)
{
await SendUnsentDiscordLogsAsync();
}
// Don't send unsent logs on initial load - only send newly fetched logs going forward
// This prevents spamming Discord with old logs when app starts

// Start polling timer (60 seconds)
// Start polling timer with configurable interval
var pollingIntervalMs = _settingsService.Settings.AuditLogPollingIntervalSeconds * 1000;
_pollingTimer?.Dispose();
_pollingTimer = new Timer(60000); // 60 seconds
_pollingTimer = new Timer(pollingIntervalMs);
_pollingTimer.Elapsed += async (s, e) => await PollForNewLogsAsync();
_pollingTimer.AutoReset = true;
_pollingTimer.Start();
Expand All @@ -98,8 +99,8 @@ public async Task StartPollingAsync(string groupId)
Console.WriteLine("[AUDIT-SVC] Starting initial poll...");
await PollForNewLogsAsync();

Console.WriteLine("[AUDIT-SVC] Polling timer started (60 second interval)");
StatusChanged?.Invoke(this, $"▶ Polling active - auto-checking every 60s | Total: {_totalLogCount} logs");
Console.WriteLine($"[AUDIT-SVC] Polling timer started ({_settingsService.Settings.AuditLogPollingIntervalSeconds} second interval)");
StatusChanged?.Invoke(this, $"▶ Polling active - auto-checking every {_settingsService.Settings.AuditLogPollingIntervalSeconds}s | Total: {_totalLogCount} logs");
}

public void StopPolling()
Expand Down Expand Up @@ -156,8 +157,11 @@ private async Task PollForNewLogsAsync()

if (newLogs.Count > 0)
{
// Find truly new logs (not in existing)
var trulyNewLogs = newLogs.Where(l => !existingIds.Contains(l.Id)).ToList();
// Find truly new logs (not in existing) AND not older than configured max age
var maxAgeMinutes = _settingsService.Settings.AuditLogDiscordNotificationMaxAgeMinutes;
var cutoffTime = DateTime.UtcNow.AddMinutes(-maxAgeMinutes);
var trulyNewLogs = newLogs.Where(l => !existingIds.Contains(l.Id) && l.CreatedAt >= cutoffTime).ToList();
Console.WriteLine($"[AUDIT-SVC] Found {trulyNewLogs.Count} new logs within last {maxAgeMinutes} minutes (filtered from {newLogs.Where(l => !existingIds.Contains(l.Id)).Count()} total new logs)");

var savedCount = await _cacheService.AppendAuditLogsAsync(_currentGroupId, newLogs);
_totalLogCount = await _cacheService.GetAuditLogCountAsync(_currentGroupId);
Expand Down Expand Up @@ -497,6 +501,29 @@ private async Task<bool> SendDiscordNotificationAsync(AuditLogEntry log)
}
}

private async Task<bool> SendDiscordNotificationAsync(AuditLogEntity log)
{
try
{
if (_discordService is DiscordWebhookService discordSvc)
{
var success = await discordSvc.SendAuditEventAsync(
log.EventType,
log.ActorName ?? "Unknown",
log.TargetName,
log.Description
);
return success;
}
return false;
}
catch (Exception ex)
{
Console.WriteLine($"[AUDIT-SVC] Discord notification failed: {ex.Message}");
return false;
}
}

private async Task SendUnsentDiscordLogsAsync()
{
if (string.IsNullOrEmpty(_currentGroupId))
Expand Down Expand Up @@ -614,6 +641,25 @@ private static string BuildDiscordDedupKey(AuditLogEntry log)
});
}

private static string BuildDiscordDedupKey(AuditLogEntity log)
{
if (!string.IsNullOrWhiteSpace(log.RawData))
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(log.RawData);
return Convert.ToHexString(sha.ComputeHash(bytes));
}

return string.Join("|", new[]
{
log.EventType,
log.ActorId ?? string.Empty,
log.TargetId ?? string.Empty,
log.Description ?? string.Empty,
log.CreatedAt.ToUniversalTime().ToString("o")
});
}

public void Dispose()
{
StopPolling();
Expand Down
Loading