Files
Vanadium-2023-2025-Leak-Server/Services/DiscordPlayerCount/GeneralPurposeBotDiscord.cs
T
skidfire fe3468c65d yikes,
2026-09-06 19:59:27 -04:00

690 lines
30 KiB
C#

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<string, PendingLink> _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<byte>(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<byte>(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<byte>(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<byte>(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<byte>(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<object>();
foreach (var versionGroup in versionGroups)
{
var activeInstancesLines = new List<string>();
var dormLines = new List<string>();
var privateRoomLines = new List<string>();
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: <t:" + (long)(startTimeUnix) + ":t>\n* Server Uptime: <t:" + (long)(startTimeUnix) + ":R>"
};
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<Vanadium.Classes.DBs.DBClasses.PlayerDBClasses.MessageData>();
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<long>()
};
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<InventionTag> { 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 */
}
}