using System.Collections.Concurrent; using System.Diagnostics; using System.Net.Http.Headers; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Discord; using Discord.WebSocket; using Vanadium.Classes; using Vanadium.Classes.DBs; using Vanadium.Classes.DBs.DBClasses; using Vanadium.Controllers; using static Vanadium.Classes.DBs.DBClasses.InventionDBClasses; using static Vanadium.Classes.DBs.DBClasses.RoomDBClasses; namespace Vanadium.Services { public class PlayerCountService : BackgroundService { public class PendingLink { public ulong DiscordUserId { get; init; } public string DiscordUsername { get; init; } = ""; public DateTime ExpiresAt { get; init; } } private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15); private static readonly ConcurrentDictionary _codes = new(); private static DiscordSocketClient? _discordClient; private static readonly HttpClient _http = new(); private static ClientWebSocket? _ws; private static readonly string ConfigPath = Path.Combine( Environment.CurrentDirectory, "Services", "DiscordPlayerCount", "Config.json"); private static readonly string ErrorLogPath = Path.Combine( Environment.CurrentDirectory, "Services", "DiscordPlayerCount", "errors.txt"); private const string UploadAudioErrorWebhook = "https://discord.com/api/webhooks/1543130746254794853/3Y0nd5PydrGZAV_9kA2JjC2Z6n4DSAVLyPVjBadHVJlMjztxBRAl9Iu7AhM6u4BJ7BWm"; private static async Task LogErrorAsync(string message) { string line = $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC] {message}"; Console.WriteLine($"[PlayerCount] {line}"); try { await File.AppendAllTextAsync(ErrorLogPath, line + Environment.NewLine); } catch { } } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _ = MaintainGatewayConnectionAsync(stoppingToken); _ = InitDiscordClientAsync(); while (!stoppingToken.IsCancellationRequested) { await UpdateMessageAndStatusAsync(); await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } } private async Task MaintainGatewayConnectionAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { _ws = new ClientWebSocket(); await _ws.ConnectAsync(new Uri("wss://gateway.discord.gg/?v=10&encoding=json"), stoppingToken); var buffer = new byte[4096]; var result = await _ws.ReceiveAsync(new ArraySegment(buffer), stoppingToken); string helloJson = Encoding.UTF8.GetString(buffer, 0, result.Count); using var doc = JsonDocument.Parse(helloJson); int heartbeatInterval = doc.RootElement.GetProperty("d").GetProperty("heartbeat_interval").GetInt32(); var identifyPayload = new { op = 2, d = new { token = ServerConfig.CountBotToken, intents = 0, properties = new { os = "linux", browser = "bro", device = "bro" } } }; byte[] identifyBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(identifyPayload)); await _ws.SendAsync(new ArraySegment(identifyBytes), WebSocketMessageType.Text, true, stoppingToken); _ = HeartbeatLoopAsync(heartbeatInterval, stoppingToken); while (_ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested) { var receiveBuffer = new byte[4096]; var res = await _ws.ReceiveAsync(new ArraySegment(receiveBuffer), stoppingToken); if (res.MessageType == WebSocketMessageType.Close) break; } } catch (Exception ex) { await LogErrorAsync($"Gateway connection error: {ex}"); await Task.Delay(5000, stoppingToken); } } } private async Task HeartbeatLoopAsync(int intervalMs, CancellationToken stoppingToken) { while (_ws != null && _ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested) { await Task.Delay(intervalMs, stoppingToken); if (_ws.State != WebSocketState.Open) break; var heartbeat = new { op = 1, d = (object?)null }; byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(heartbeat)); await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, stoppingToken); } } private static async Task UpdateMessageAndStatusAsync() { try { var connectedPlayerIds = NotificationsController.PlayerConnections.Keys.ToHashSet(); var inInstancePlayers = PlayerDB.Players.FindAll() .Where(p => p.Player?.PlayerExtra?.Heartbeat?.isOnline == true && connectedPlayerIds.Contains(p.PlayerId) && p.Player?.PlayerExtra?.Heartbeat?.roomInstance != null) .ToList(); int playerCount = inInstancePlayers.Count; if (_ws != null && _ws.State == WebSocketState.Open) { string presencePlayerText = playerCount == 1 ? "player" : "players"; var presencePayload = new { op = 3, d = new { since = (object?)null, activities = new[] { new { name = $"{playerCount} {presencePlayerText} online.", type = 3 } }, status = "online", afk = false } }; byte[] presenceBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(presencePayload)); await _ws.SendAsync(new ArraySegment(presenceBytes), WebSocketMessageType.Text, true, CancellationToken.None); } if (!File.Exists(ConfigPath)) return; using var configDoc = JsonDocument.Parse(await File.ReadAllTextAsync(ConfigPath)); var root = configDoc.RootElement; if (!root.TryGetProperty("channelId", out var cid) || string.IsNullOrWhiteSpace(cid.GetString())) return; if (!root.TryGetProperty("messageId", out var mid) || string.IsNullOrWhiteSpace(mid.GetString())) return; string channelId = cid.GetString()!; string messageId = mid.GetString()!; var versionGroups = inInstancePlayers .GroupBy(p => p.Player?.PlayerExtra?.Heartbeat?.appVersion ?? "Unknown") .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) .ToList(); var embeds = new List(); foreach (var versionGroup in versionGroups) { var activeInstancesLines = new List(); var dormLines = new List(); var privateRoomLines = new List(); int versionPlayerCount = versionGroup.Count(); string versionPlayerText = versionPlayerCount == 1 ? "player" : "players"; string versionPrefix = versionPlayerCount == 1 ? "There's" : "There are"; string versionLabel = string.IsNullOrWhiteSpace(versionGroup.Key) || versionGroup.Key.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ? "Unknown Version" : versionGroup.Key; foreach (var p in versionGroup) { string displayName = p.Player?.DisplayName ?? p.Player?.Username ?? "Unknown"; var ri = p.Player?.PlayerExtra?.Heartbeat?.roomInstance!; var deviceClass = p.Player?.PlayerExtra?.Heartbeat?.deviceClass == PlayerDBClasses.DeviceClasses.Quest2 ? " <:Oculus:1531704664209489980>" : ""; string strippedName = ri.Name?.TrimStart('^') ?? ""; bool isDorm = strippedName.StartsWith("DormRoom", StringComparison.OrdinalIgnoreCase) || (ri.roomId > 0 && RoomDB.GetRoom(ri.roomId)?.Name?.StartsWith("DormRoom", StringComparison.OrdinalIgnoreCase) == true); if (isDorm) { string ownerName = displayName; if (!string.IsNullOrWhiteSpace(strippedName) && strippedName.Contains('#')) { string potentialOwner = strippedName.Split('#')[0]; if (!string.IsNullOrWhiteSpace(potentialOwner) && !potentialOwner.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)) { ownerName = potentialOwner; } } dormLines.Add($"**{displayName}{deviceClass}** - @{ownerName}'s Dorm"); continue; } var room = ri.roomId > 0 ? RoomDB.GetRoom(ri.roomId) : (!string.IsNullOrWhiteSpace(strippedName) ? RoomDB.GetRoomByName(strippedName) : null); if (room?.Accessibility == RoomAccessibility.Private) { privateRoomLines.Add($"**{displayName}{deviceClass}** - [PRIVATE ROOM]"); continue; } string baseName = room?.Name ?? (string.IsNullOrWhiteSpace(strippedName) ? "Unknown Room" : strippedName); string formattedRoom = ri.isPrivate ? $"^{baseName} [PRIVATE]" : $"^{baseName}"; activeInstancesLines.Add($"**{displayName}{deviceClass}** - {formattedRoom}"); } var sb = new StringBuilder(); if (activeInstancesLines.Count > 0) { sb.AppendLine("* *Active Instances* \n"); foreach (var line in activeInstancesLines) sb.AppendLine(line); sb.AppendLine(); } if (dormLines.Count > 0) { sb.AppendLine("* *Dorms* \n"); foreach (var line in dormLines) sb.AppendLine(line); sb.AppendLine(); } if (privateRoomLines.Count > 0) { sb.AppendLine("* *Private Rooms* \n"); foreach (var line in privateRoomLines) sb.AppendLine(line); sb.AppendLine(); } string description; if (versionPlayerCount == 0) { description = "*No players online.*"; } else { description = sb.ToString(); } var embed = new { color = 5757183, title = $"{versionPrefix} {versionPlayerCount} {versionPlayerText} online in {versionLabel}.", description }; embeds.Add(embed); } DateTime startTime = Process.GetCurrentProcess().StartTime; long startTimeUnix = (long)(startTime.ToUniversalTime() - new DateTime(1970, 1, 1)).TotalSeconds; var beginningEmbed = new { author = new { name = "Vanadium Player Count", icon_url = "https://reloxa.xyz/imageserver/pfp_1806_1785439345432.png" }, description = "*Updates every 15 seconds.\n-# Version and platform may not be accurate!*\n* Server Startup Time: \n* Server Uptime: " }; embeds.Insert(0, beginningEmbed); var payload = JsonSerializer.Serialize(new { content = (string?)null, embeds = embeds.ToArray() }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage( HttpMethod.Patch, $"https://discord.com/api/v10/channels/{channelId}/messages/{messageId}") { Content = content }; request.Headers.Authorization = new AuthenticationHeaderValue("Bot", ServerConfig.CountBotToken); var response = await _http.SendAsync(request); if (!response.IsSuccessStatusCode) { string body = await response.Content.ReadAsStringAsync(); await LogErrorAsync($"Discord PATCH failed — HTTP {(int)response.StatusCode} {response.ReasonPhrase}: {body}"); } } catch (Exception ex) { await LogErrorAsync($"UpdateMessageAndStatusAsync exception: {ex}"); } } private async Task InitDiscordClientAsync() { if (string.IsNullOrWhiteSpace(ServerConfig.CountBotToken)) return; _discordClient = new DiscordSocketClient(new DiscordSocketConfig { GatewayIntents = GatewayIntents.Guilds, LogLevel = LogSeverity.Warning }); _discordClient.Ready += OnDiscordReady; _discordClient.SlashCommandExecuted += HandleSlashCommand; await _discordClient.LoginAsync(TokenType.Bot, ServerConfig.CountBotToken); await _discordClient.StartAsync(); } private async Task OnDiscordReady() { var link = new SlashCommandBuilder() .WithName("link") .WithDescription("Link your discord account to vanadium to play 2025 build (limited to booster tho)") .AddOption("username", ApplicationCommandOptionType.String, "Your Vanadium username", isRequired: true) .Build(); await _discordClient!.CreateGlobalApplicationCommandAsync(link); var whoami = new SlashCommandBuilder() .WithName("whoami") .WithDescription("tells you who you are in vanadium") .Build(); await _discordClient!.CreateGlobalApplicationCommandAsync(whoami); var changePassword = new SlashCommandBuilder() .WithName("change-password") .WithDescription("to change your vanadium password if linked") .AddOption("password", ApplicationCommandOptionType.String, "Your new password", isRequired: true) .Build(); await _discordClient!.CreateGlobalApplicationCommandAsync(changePassword); var unlink = new SlashCommandBuilder() .WithName("unlink") .WithDescription("Unlink your discord account from vanadium") .Build(); await _discordClient!.CreateGlobalApplicationCommandAsync(unlink); var uploadAudio = new SlashCommandBuilder() .WithName("upload-audio") .WithDescription("Import audio as an invention (linked account required)") .AddOption("name", ApplicationCommandOptionType.String, "Invention name", isRequired: true) .AddOption("audio_file", ApplicationCommandOptionType.Attachment, "audio to import (Supported files: mp3/4, ogg, htr, mov) - max 10 min / 10 MB", isRequired: true) .Build(); await _discordClient!.CreateGlobalApplicationCommandAsync(uploadAudio); } /* start link stuff */ private async Task HandleSlashCommand(SocketSlashCommand cmd) { switch (cmd.CommandName) { case "link": await HandleLinkCommand(cmd); break; case "whoami": await HandleWhoamiCommand(cmd); break; case "change-password": await HandleChangePasswordCommand(cmd); break; case "unlink": await HandleUnlinkCommand(cmd); break; case "upload-audio": await HandleUploadAudioCommand(cmd); break; } } private async Task HandleLinkCommand(SocketSlashCommand cmd) { string discordId = cmd.User.Id.ToString(); var existingLinked = PlayerDB.Players.FindOne(p => p.Player != null && p.Player.DiscordLinked && p.Player.DiscordUserId == discordId); if (existingLinked != null) { await cmd.RespondAsync($"**Your Discord is already linked to @{existingLinked.Player.Username}. Use /unlink first.**", ephemeral: true); return; } var username = (string)cmd.Data.Options.First().Value; var player = PlayerDB.Players.FindOne(p => p.Player != null && p.Player.Username.ToLower() == username.ToLower()); if (player == null) { await cmd.RespondAsync("No vanadium accounts with that username were found", ephemeral: true); return; } if (player.Player.DiscordLinked) { await cmd.RespondAsync("**This user already has an account linked.**", ephemeral: true); return; } string linkCode = GenerateCode(cmd.User.Id); string coachPin = new Random().Next(100000, 999999).ToString(); Program.PendingInGameCodes[coachPin] = (player.PlayerId, cmd.User.Id); var messageData = new Vanadium.Classes.DBs.DBClasses.PlayerDBClasses.MessageData { Id = new Random().Next(1, 0x7ffffff), FromPlayerId = 1, SentTime = DateTime.UtcNow, Type = (Vanadium.Classes.DBs.DBClasses.PlayerDBClasses.MessageType)100, Data = $"Your pin code is \"{coachPin}\", don't share this or else you'd be giving someone else access to the 2025 build.", RoomId = 1 }; if (player.Player.PlayerExtra.Messages == null) player.Player.PlayerExtra.Messages = new List(); player.Player.PlayerExtra.Messages.Add(messageData); PlayerDB.Players.Update(player); var msg = new { Id = "2", Msg = messageData }; _ = NotificationsController.SendToPlayer(player.PlayerId, Newtonsoft.Json.JsonConvert.SerializeObject(msg)); await cmd.RespondAsync($"Message was sent to the username \"**{player.Player.Username}**\" \n Verify At: https://reloxa.xyz/beta/verifydiscord", ephemeral: true); } private async Task HandleWhoamiCommand(SocketSlashCommand cmd) { string discordId = cmd.User.Id.ToString(); var player = PlayerDB.Players.FindOne(p => p.Player != null && p.Player.DiscordLinked && p.Player.DiscordUserId == discordId); if (player == null) { await cmd.RespondAsync("use **/link** to link your account. It isn't linked currently.", ephemeral: true); return; } await cmd.RespondAsync($"**@{player.Player.Username}**, {player.Player.DisplayName}", ephemeral: true); } private async Task HandleChangePasswordCommand(SocketSlashCommand cmd) { string discordId = cmd.User.Id.ToString(); var player = PlayerDB.Players.FindOne(p => p.Player != null && p.Player.DiscordLinked && p.Player.DiscordUserId == discordId); if (player == null) { await cmd.RespondAsync("use **/link** to link your account. It isn't linked currently.", ephemeral: true); return; } string newPassword = (string)cmd.Data.Options.First().Value; PlayerDB.PasswordManager.ChangePassword(player.PlayerId, newPassword); await cmd.RespondAsync("Password was updated successfully.", ephemeral: true); } private async Task HandleUnlinkCommand(SocketSlashCommand cmd) { string discordId = cmd.User.Id.ToString(); var player = PlayerDB.Players.FindOne(p => p.Player != null && p.Player.DiscordLinked && p.Player.DiscordUserId == discordId); if (player == null) { await cmd.RespondAsync("Bro, you aint even linked an account in the first place", ephemeral: true); return; } string username = player.Player.Username ?? ""; player.Player.DiscordLinked = false; player.Player.DiscordUserId = null; player.Player.DiscordUsername = null; player.Player.DiscordLinkedAt = null; PlayerDB.Players.Update(player); await cmd.RespondAsync($"Unlinked discord from **@{username}**", ephemeral: true); } private async Task HandleUploadAudioCommand(SocketSlashCommand cmd) { string discordId = cmd.User.Id.ToString(); var player = PlayerDB.Players.FindOne(p => p.Player != null && p.Player.DiscordLinked && p.Player.DiscordUserId == discordId); if (player == null) { await cmd.RespondAsync("Link your account with **/link** to use this command.", ephemeral: true); return; } await cmd.DeferAsync(ephemeral: true); string inventionName = (string)cmd.Data.Options.First(o => o.Name == "name").Value; var attachment = (IAttachment)cmd.Data.Options.First(o => o.Name == "audio_file").Value; try { string ext = Path.GetExtension(attachment.Filename).ToLowerInvariant(); string[] allowed = { ".mp3", ".mp4", ".mov", ".htr", ".wav", ".ogg" }; if (!allowed.Contains(ext)) { await cmd.ModifyOriginalResponseAsync(m => m.Content = "Unsupported file type."); await SendErrorWebhookAsync($"upload-audio: unsupported file type `{ext}` from discord user {discordId}"); return; } if (attachment.Size > 10 * 1024 * 1024) { await cmd.ModifyOriginalResponseAsync(m => m.Content = "File must be under 10 MB."); return; } byte[] fileBytes; using (var dlClient = new HttpClient()) fileBytes = await dlClient.GetByteArrayAsync(attachment.Url); string htrStoredName; string invStoredName; if (ext == ".htr") { htrStoredName = Guid.NewGuid().ToString("N") + ".htr"; string htrDir = Path.GetFullPath(Path.Join(Program.dataDir, "cdn", "htr")); Directory.CreateDirectory(htrDir); await File.WriteAllBytesAsync(Path.Combine(htrDir, htrStoredName), fileBytes); } else { using var makeHtrForm = new MultipartFormDataContent(); makeHtrForm.Add(new ByteArrayContent(fileBytes), "audio", attachment.Filename); using var apiClient = new HttpClient(); var htrResponse = await apiClient.PostAsync("https://vanadium.reloxa.xyz/audio/makeHtr", makeHtrForm); if (!htrResponse.IsSuccessStatusCode) { string body = await htrResponse.Content.ReadAsStringAsync(); throw new Exception($"makeHtr failed {(int)htrResponse.StatusCode}: {body}"); } byte[] htrBytes = await htrResponse.Content.ReadAsByteArrayAsync(); htrStoredName = Guid.NewGuid().ToString("N") + ".htr"; string htrDir = Path.GetFullPath(Path.Join(Program.dataDir, "cdn", "htr")); Directory.CreateDirectory(htrDir); await File.WriteAllBytesAsync(Path.Combine(htrDir, htrStoredName), htrBytes); } using var makeInvForm = new MultipartFormDataContent(); makeInvForm.Add(new StringContent(htrStoredName), "name"); using var invApiClient = new HttpClient(); var invResponse = await invApiClient.PostAsync("https://vanadium.reloxa.xyz/audio/makeInv", makeInvForm); if (!invResponse.IsSuccessStatusCode) { string body = await invResponse.Content.ReadAsStringAsync(); throw new Exception($"makeInv failed {(int)invResponse.StatusCode}: {body}"); } byte[] invBytes = await invResponse.Content.ReadAsByteArrayAsync(); invStoredName = Guid.NewGuid().ToString("N") + ".inv"; string invDir = Path.GetFullPath(Path.Join(Program.dataDir, "cdn", "inv")); Directory.CreateDirectory(invDir); await File.WriteAllBytesAsync(Path.Combine(invDir, invStoredName), invBytes); var saveRequest = new SaveInventionRequest { name = inventionName, description = "Imported Audio through Discord.", imageName = "DefaultRoomImage.jpg", instantiationCost = 0, lightsCost = 0, chipsCost = 0, cloudVariablesCost = 0, aiCost = 0, inkCost = 1, creationRoomId = 1, inventionDataFilename = invStoredName, referencedInventions = new List() }; var result = InventionDB.SaveInvention(player.PlayerId, saveRequest); if (result == null) throw new Exception("InventionDB.SaveInvention returned null"); var invention = result.Value.invention; invention.LongDescription = "Imported Audio through Discord."; invention.ForceCannotPublish = true; invention.IsRecRoomApproved = true; invention.AllowTrial = false; invention.Tags = new List { new InventionTag { Tag = "imported", Type = 1 } }; InventionDB.Inventions.Update(invention); await cmd.ModifyOriginalResponseAsync(m => m.Content = $"Invention \"**{inventionName}**\" imported successfully."); } catch (Exception ex) { await cmd.ModifyOriginalResponseAsync(m => m.Content = "Servers decided to say no, this has been logged and will likely be fixed within a few hours."); await SendErrorWebhookAsync($"upload-audio exception from discord user {discordId}: {ex}"); } } private static async Task SendErrorWebhookAsync(string message) { try { var payload = JsonSerializer.Serialize(new { embeds = new[] { new { title = "upload-audio error", description = message.Length > 4000 ? message[..4000] : message, color = 15158332 } } }); await _http.PostAsync(UploadAudioErrorWebhook, new StringContent(payload, Encoding.UTF8, "application/json")); } catch { } } private static string GenerateCode(ulong discordUserId) { PruneExpired(); _codes.Where(kv => kv.Value.DiscordUserId == discordUserId) .Select(kv => kv.Key).ToList() .ForEach(k => _codes.TryRemove(k, out _)); string code; do { code = RandomNumberGenerator.GetInt32(0, 100_000_000).ToString("D8"); } while (_codes.ContainsKey(code)); return code; } public static PendingLink? ConsumeCode(string? code) { PruneExpired(); if (string.IsNullOrWhiteSpace(code) || !_codes.TryRemove(code.Trim(), out var pending)) return null; if (DateTime.UtcNow > pending.ExpiresAt) return null; return pending; } private static void PruneExpired() { var now = DateTime.UtcNow; foreach (var kv in _codes) if (now > kv.Value.ExpiresAt) _codes.TryRemove(kv.Key, out _); } /* end link stuff */ } }