Register via bot

This commit is contained in:
zheneq 2026-08-01 00:37:02 +04:00
parent aebaa3ea39
commit e9a77b3878
18 changed files with 803 additions and 43 deletions

View file

@ -8,16 +8,27 @@ namespace EvoS.Framework.DataAccess.Daos
public interface RegistrationCodeDao
{
public const int LIMIT = 25;
public RegistrationCodeEntry Find(string code);
public List<RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime);
public List<RegistrationCodeEntry> FindAll(int limit, int offset);
public List<RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime);
public List<RegistrationCodeEntry> FindAllIssued(int limit, int offset);
public List<RegistrationCodeEntry> FindByState(RegistrationState state, int limit);
public RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId);
public void Save(RegistrationCodeEntry entry);
// Issued = 0 so legacy rows and manually issued codes (with no State field) are treated as issued.
public enum RegistrationState
{
Issued = 0,
Requested = 1,
Declined = 2
}
public class RegistrationCodeEntry
{
[BsonId]
public string Code;
public RegistrationState State;
public long IssuedBy;
public string IssuedTo;
public DateTime IssuedAt;
@ -25,6 +36,15 @@ namespace EvoS.Framework.DataAccess.Daos
public DateTime UsedAt;
public long UsedBy;
public ulong DiscordUserId;
public string DiscordUserName;
public string DiscordDisplayName;
public string DiscordAvatarUrl;
public DateTime DiscordCreatedAt;
public DateTime? DiscordJoinedAt;
public string DeclineReason;
public DateTime RequestedAt;
[JsonIgnore]
public bool IsValid => !IsUsed && !HasExpired;
@ -39,14 +59,23 @@ namespace EvoS.Framework.DataAccess.Daos
return new RegistrationCodeEntry
{
Code = Code,
State = State,
IssuedBy = IssuedBy,
IssuedTo = IssuedTo,
IssuedAt = IssuedAt,
ExpiresAt = ExpiresAt,
UsedAt = DateTime.UtcNow,
UsedBy = accountId
UsedBy = accountId,
DiscordUserId = DiscordUserId,
DiscordUserName = DiscordUserName,
DiscordDisplayName = DiscordDisplayName,
DiscordAvatarUrl = DiscordAvatarUrl,
DiscordCreatedAt = DiscordCreatedAt,
DiscordJoinedAt = DiscordJoinedAt,
DeclineReason = DeclineReason,
RequestedAt = RequestedAt
};
}
}
}
}
}

View file

@ -38,40 +38,81 @@ namespace EvoS.Framework.DataAccess.Daos
return nonCachedEntry;
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime)
private static bool IsIssued(RegistrationCodeDao.RegistrationCodeEntry entry) =>
entry.State != RegistrationCodeDao.RegistrationState.Requested
&& entry.State != RegistrationCodeDao.RegistrationState.Declined;
public List<RegistrationCodeDao.RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime)
{
if (dao is RegistrationCodeMockDao)
{
return cache
.Select(x => x.Value)
.Where(x => x.IssuedAt < dateTime)
.Where(x => IsIssued(x) && x.IssuedAt < dateTime)
.OrderByDescending(x => x.IssuedAt)
.Take(limit)
.ToList();
}
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindBefore(limit, dateTime);
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindIssuedBefore(limit, dateTime);
daoEntries.ForEach(Cache);
return daoEntries;
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAll(int limit, int offset)
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAllIssued(int limit, int offset)
{
if (dao is RegistrationCodeMockDao)
{
return cache
.Select(x => x.Value)
.Where(IsIssued)
.OrderByDescending(x => x.IssuedAt)
.Skip(offset)
.Take(limit)
.ToList();
}
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindAll(limit, offset);
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindAllIssued(limit, offset);
daoEntries.ForEach(Cache);
return daoEntries;
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindByState(RegistrationCodeDao.RegistrationState state, int limit)
{
if (dao is RegistrationCodeMockDao)
{
return cache
.Select(x => x.Value)
.Where(x => x.State == state)
.OrderByDescending(x => x.RequestedAt)
.Take(limit)
.ToList();
}
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindByState(state, limit);
daoEntries.ForEach(Cache);
return daoEntries;
}
public RegistrationCodeDao.RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId)
{
if (dao is RegistrationCodeMockDao)
{
return cache
.Select(x => x.Value)
.Where(x => x.DiscordUserId == discordUserId)
.OrderByDescending(x => x.RequestedAt)
.FirstOrDefault();
}
RegistrationCodeDao.RegistrationCodeEntry entry = dao.FindLatestByDiscordUser(discordUserId);
if (entry != null)
{
Cache(entry);
}
return entry;
}
public void Save(RegistrationCodeDao.RegistrationCodeEntry entry)
{
dao.Save(entry);

View file

@ -20,7 +20,7 @@ namespace EvoS.DirectoryServer.Account
{
private static readonly ILog log = LogManager.GetLogger(typeof(LoginManager));
private static readonly HashAlgorithm algorithm = SHA256.Create();
private static readonly Regex usernameRegex = new Regex(@"^[A-Za-z][A-Za-z_\-0-9]{3,}$");
private static readonly Regex usernameRegex = new Regex(@"^[A-Za-z][A-Za-z_\-0-9]{3,23}$");
private static readonly Regex bannedUsernameRegex = new Regex(@"^(?:(?:changeMeToYour)?user(?:name)?|admin|draft|gaia|maps)$", RegexOptions.IgnoreCase);
private static readonly Regex bannedPasswordRegex = new Regex(@"^(?:(?:changeMeToYour)?password)$", RegexOptions.IgnoreCase);
@ -28,7 +28,7 @@ namespace EvoS.DirectoryServer.Account
public const string UserDoesNotExist = "User does not exist";
public const string InvalidUsername = "Invalid username. " +
"Please use only latin characters, numbers, underscore and dash, and start with a letter. " +
"4 symbols or more.";
"4 to 24 symbols.";
public const string CannotUseThisUsername = "You cannot use this username. Please choose another.";
public const string CannotUseThisPassword = "You cannot use this password. Please choose another.";
public const string FailedToCreateAnAccount = "Failed to crate an account";

View file

@ -11,16 +11,26 @@ namespace EvoS.Framework.DataAccess.Mock
return null;
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime)
public List<RegistrationCodeDao.RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime)
{
return new List<RegistrationCodeDao.RegistrationCodeEntry>();
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAll(int limit, int offset)
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAllIssued(int limit, int offset)
{
return new List<RegistrationCodeDao.RegistrationCodeEntry>();
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindByState(RegistrationCodeDao.RegistrationState state, int limit)
{
return new List<RegistrationCodeDao.RegistrationCodeEntry>();
}
public RegistrationCodeDao.RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId)
{
return null;
}
public void Save(RegistrationCodeDao.RegistrationCodeEntry entry)
{
}

View file

@ -8,39 +8,63 @@ namespace EvoS.Framework.DataAccess.Mongo
public class RegistrationCodeMongoDao : MongoDao<string, RegistrationCodeDao.RegistrationCodeEntry>, RegistrationCodeDao
{
public RegistrationCodeMongoDao() : base(
"registration_codes",
"registration_codes",
new CreateIndexModel<RegistrationCodeDao.RegistrationCodeEntry>(Builders<RegistrationCodeDao.RegistrationCodeEntry>.IndexKeys
.Descending(entry => entry.IssuedAt)))
.Descending(entry => entry.IssuedAt)),
new CreateIndexModel<RegistrationCodeDao.RegistrationCodeEntry>(Builders<RegistrationCodeDao.RegistrationCodeEntry>.IndexKeys
.Ascending(entry => entry.DiscordUserId)))
{
}
private FilterDefinition<RegistrationCodeDao.RegistrationCodeEntry> IssuedOnly =>
f.And(
f.Ne("State", RegistrationCodeDao.RegistrationState.Requested),
f.Ne("State", RegistrationCodeDao.RegistrationState.Declined));
public RegistrationCodeDao.RegistrationCodeEntry Find(string code)
{
return c.Find(f.Eq("Code", code)).FirstOrDefault();
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime)
public List<RegistrationCodeDao.RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime)
{
return c
.Find(f.Lt("IssuedAt", dateTime))
.Find(f.And(IssuedOnly, f.Lt("IssuedAt", dateTime)))
.Sort(s.Descending("IssuedAt"))
.Limit(limit)
.ToList();
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAll(int limit, int offset)
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAllIssued(int limit, int offset)
{
return c
.Find(f.Empty)
.Find(IssuedOnly)
.Sort(s.Descending("IssuedAt"))
.Skip(offset)
.Limit(limit)
.ToList();
}
public List<RegistrationCodeDao.RegistrationCodeEntry> FindByState(RegistrationCodeDao.RegistrationState state, int limit)
{
return c
.Find(f.Eq("State", state))
.Sort(s.Descending("RequestedAt"))
.Limit(limit)
.ToList();
}
public RegistrationCodeDao.RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId)
{
return c
.Find(f.Eq("DiscordUserId", discordUserId))
.Sort(s.Descending("RequestedAt"))
.FirstOrDefault();
}
public void Save(RegistrationCodeDao.RegistrationCodeEntry entry)
{
insert(entry.Code, entry);
}
}
}
}

View file

@ -71,6 +71,9 @@ public class AdminApiServer : ApiServer
app.MapPost("/api/admin/player/registrationCode", AdminController.IssueRegistrationCode).RequireAuthorization("api_admin");
app.MapPost("/api/admin/player/mapPickBan", AdminController.StartMapPickBan).RequireAuthorization("api_admin");
app.MapGet("/api/admin/player/registrationCode", AdminController.GetRegistrationCodes).RequireAuthorization("api_admin");
app.MapGet("/api/admin/player/usernameRequests", AdminController.GetUsernameRequests).RequireAuthorization("api_admin");
app.MapPost("/api/admin/player/usernameRequest/confirm", AdminController.ConfirmUsernameRequest).RequireAuthorization("api_admin");
app.MapPost("/api/admin/player/usernameRequest/decline", AdminController.DeclineUsernameRequest).RequireAuthorization("api_admin");
app.MapGet("/api/admin/player/matches", MatchController.GetMatchHistory).RequireAuthorization("api_admin");
app.MapGet("/api/admin/moderation/chatHistory", ModerationController.GetChatHistory).RequireAuthorization("api_admin");
app.MapGet("/api/admin/moderation/sentFeedback", ModerationController.GetSentFeedback).RequireAuthorization("api_admin");

View file

@ -8,6 +8,7 @@ using CentralServer.LobbyServer;
using CentralServer.LobbyServer.Chat;
using CentralServer.LobbyServer.Config;
using CentralServer.LobbyServer.CustomGames;
using CentralServer.LobbyServer.Discord;
using CentralServer.LobbyServer.Matchmaking;
using CentralServer.LobbyServer.Session;
using CentralServer.LobbyServer.Utils;
@ -487,6 +488,10 @@ namespace CentralServer.ApiServer
public DateTime IssuedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime UsedAt { get; set; }
public string DiscordUserId { get; set; }
public string DiscordUserName { get; set; }
public string DiscordDisplayName { get; set; }
public string DiscordAvatarUrl { get; set; }
public static RegistrationCodeEntryModel Of(RegistrationCodeDao.RegistrationCodeEntry e)
{
@ -500,6 +505,10 @@ namespace CentralServer.ApiServer
IssuedAt = e.IssuedAt,
ExpiresAt = e.ExpiresAt,
UsedAt = e.UsedAt,
DiscordUserId = e.DiscordUserId == 0 ? null : e.DiscordUserId.ToString(),
DiscordUserName = e.DiscordUserName,
DiscordDisplayName = e.DiscordDisplayName,
DiscordAvatarUrl = e.DiscordAvatarUrl,
};
}
}
@ -523,13 +532,128 @@ namespace CentralServer.ApiServer
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
List<RegistrationCodeEntryModel> entries = (before > 0
? dao.FindBefore(limit, DateTimeOffset.FromUnixTimeSeconds(before).UtcDateTime)
: dao.FindAll(limit, offset))
? dao.FindIssuedBefore(limit, DateTimeOffset.FromUnixTimeSeconds(before).UtcDateTime)
: dao.FindAllIssued(limit, offset))
.Select(RegistrationCodeEntryModel.Of)
.ToList();
return Results.Ok(new RegistrationCodesResponseModel { entries = entries });
}
public class UsernameRequestEntryModel
{
public string Code { get; set; }
public string RequestedUsername { get; set; }
public string DiscordUserId { get; set; }
public string DiscordUserName { get; set; }
public string DiscordDisplayName { get; set; }
public string DiscordAvatarUrl { get; set; }
public DateTime DiscordCreatedAt { get; set; }
public DateTime? DiscordJoinedAt { get; set; }
public DateTime RequestedAt { get; set; }
public static UsernameRequestEntryModel Of(RegistrationCodeDao.RegistrationCodeEntry e)
{
return new UsernameRequestEntryModel
{
Code = e.Code,
RequestedUsername = e.IssuedTo,
DiscordUserId = e.DiscordUserId.ToString(),
DiscordUserName = e.DiscordUserName,
DiscordDisplayName = e.DiscordDisplayName,
DiscordAvatarUrl = e.DiscordAvatarUrl,
DiscordCreatedAt = e.DiscordCreatedAt,
DiscordJoinedAt = e.DiscordJoinedAt,
RequestedAt = e.RequestedAt,
};
}
}
public class UsernameRequestsResponseModel
{
public List<UsernameRequestEntryModel> entries { get; set; }
}
public class ConfirmUsernameRequestModel
{
public string Code { get; set; }
}
public class DeclineUsernameRequestModel
{
public string Code { get; set; }
public string Reason { get; set; }
}
public static IResult GetUsernameRequests(ClaimsPrincipal user)
{
if (!ValidateAdmin(user, out IResult error, out _, out _))
{
return error;
}
List<UsernameRequestEntryModel> entries = DB.Get().RegistrationCodeDao
.FindByState(RegistrationCodeDao.RegistrationState.Requested, RegistrationCodeDao.LIMIT)
.Select(UsernameRequestEntryModel.Of)
.ToList();
return Results.Ok(new UsernameRequestsResponseModel { entries = entries });
}
public static IResult ConfirmUsernameRequest([FromBody] ConfirmUsernameRequestModel data, ClaimsPrincipal user)
{
if (!ValidateAdmin(user, out IResult error, out long adminAccountId, out string adminHandle))
{
return error;
}
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
RegistrationCodeDao.RegistrationCodeEntry entry = dao.Find(data.Code);
if (entry is null || entry.State != RegistrationCodeDao.RegistrationState.Requested)
{
return Results.NotFound(new ApiServer.ErrorResponseModel { message = "Request not found" });
}
if (DB.Get().LoginDao.Find(entry.IssuedTo) is not null)
{
return Results.Conflict(new ApiServer.ErrorResponseModel { message = "Username already in use" });
}
log.Info($"API CONFIRM USERNAME REQUEST by {adminHandle} ({adminAccountId}): {entry.IssuedTo}");
entry.State = RegistrationCodeDao.RegistrationState.Issued;
entry.IssuedBy = adminAccountId;
entry.IssuedAt = DateTime.UtcNow;
entry.ExpiresAt = EvosConfiguration.GetRegistrationCodeLifetime().Ticks > 0
? DateTime.UtcNow.Add(EvosConfiguration.GetRegistrationCodeLifetime())
: DateTime.MaxValue;
dao.Save(entry);
DiscordManager.Get().Bot?.PingUsernameRequestApproved(entry.DiscordUserId, entry.IssuedTo);
return Results.Ok();
}
public static IResult DeclineUsernameRequest([FromBody] DeclineUsernameRequestModel data, ClaimsPrincipal user)
{
if (!ValidateAdmin(user, out IResult error, out long adminAccountId, out string adminHandle))
{
return error;
}
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
RegistrationCodeDao.RegistrationCodeEntry entry = dao.Find(data.Code);
if (entry is null || entry.State != RegistrationCodeDao.RegistrationState.Requested)
{
return Results.NotFound(new ApiServer.ErrorResponseModel { message = "Request not found" });
}
log.Info($"API DECLINE USERNAME REQUEST by {adminHandle} ({adminAccountId}): {entry.IssuedTo}");
entry.State = RegistrationCodeDao.RegistrationState.Declined;
entry.IssuedBy = adminAccountId;
entry.DeclineReason = data.Reason;
dao.Save(entry);
DiscordManager.Get().Bot?.PingUsernameRequestDeclined(entry.DiscordUserId, data.Reason);
return Results.Ok();
}
public class MapPickBanModel
{
public long captainAAccountId { get; set; }

View file

@ -39,6 +39,9 @@
<None Update="Config\lobby.yaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Config\discordBot.yaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Config\storeSettings.yaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

View file

@ -0,0 +1,4 @@
Enabled: false
BotToken: "" # Enter the bot's token, leave empty to disable
BotChannelId: 0 # Channel id for 2-way chat relay (usually the LobbyChannel id)
RequestChannelId: 0 # Channel id where players request usernames and get pinged

View file

@ -10,8 +10,6 @@ GroupConfig:
CanInviteActiveOpponents: false
Discord:
Enabled: false
BotToken: "" # Enter the bots token leave empty to disable
BotChannelId: 0 # Enter the channel id for 2way chat (usualy the LobbyChannel id)
AdminChannel: # Discord Webhook for admin reports
Webhook: ""
ThreadId: # leave empty to post into the channel

View file

@ -0,0 +1,38 @@
using System.IO;
using log4net;
using YamlDotNet.Serialization;
namespace CentralServer.LobbyServer.Discord
{
public class DiscordBotConfiguration
{
private static readonly ILog log = LogManager.GetLogger(typeof(DiscordBotConfiguration));
private const string ConfigPath = "Config/discordBot.yaml";
private static DiscordBotConfiguration Instance;
public bool Enabled = false;
public string BotToken = "";
public ulong? BotChannelId;
public ulong? RequestChannelId;
public static DiscordBotConfiguration Get()
{
if (Instance == null)
{
if (File.Exists(ConfigPath))
{
var deserializer = new DeserializerBuilder().Build();
Instance = deserializer.Deserialize<DiscordBotConfiguration>(File.ReadAllText(ConfigPath));
}
else
{
log.Info($"{ConfigPath} not found, Discord bot is disabled");
Instance = new DiscordBotConfiguration();
}
}
return Instance;
}
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CentralServer.LobbyServer.Chat;
@ -6,7 +7,10 @@ using CentralServer.LobbyServer.Session;
using Discord;
using Discord.Net;
using Discord.WebSocket;
using EvoS.DirectoryServer.Account;
using EvoS.Framework.Constants.Enums;
using EvoS.Framework.DataAccess;
using EvoS.Framework.DataAccess.Daos;
using EvoS.Framework.Network.NetworkMessages;
using log4net;
using Newtonsoft.Json;
@ -21,15 +25,18 @@ namespace CentralServer.LobbyServer.Discord
private const string CMD_BROADCAST = "broadcast";
private const string CMD_QUEUE_DISABLE = "qoff";
private const string CMD_QUEUE_ENABLE = "qon";
private const string CMD_REQUEST_NAME = "register";
private const string CMD_GET_CODE = "code";
private readonly DiscordSocketClient botClient;
private static readonly DiscordSocketConfig discordConfig = new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent
};
private readonly ulong? botChannelId;
public DiscordBotWrapper(DiscordConfiguration conf)
private readonly ulong? requestChannelId;
public DiscordBotWrapper(DiscordBotConfiguration conf)
{
log.Info("Discord bot is enabled");
botClient = new DiscordSocketClient(discordConfig);
@ -42,13 +49,14 @@ namespace CentralServer.LobbyServer.Discord
log.Info("Discord bot lobby channel is enabled");
botChannelId = conf.BotChannelId;
}
requestChannelId = conf.RequestChannelId is null or 0 ? null : conf.RequestChannelId;
botClient.Log += Log;
botClient.Ready += Ready;
botClient.SlashCommandExecuted += SlashCommandHandler;
botClient.MessageReceived += ClientOnMessageReceived;
}
public async Task Login(DiscordConfiguration conf)
public async Task Login(DiscordBotConfiguration conf)
{
await botClient.LoginAsync(TokenType.Bot, conf.BotToken);
await botClient.StartAsync();
@ -81,12 +89,25 @@ namespace CentralServer.LobbyServer.Discord
.WithDefaultMemberPermissions(GuildPermission.ManageGuild)
.Build();
SlashCommandProperties requestNameCommand = new SlashCommandBuilder()
.WithName(CMD_REQUEST_NAME)
.WithDescription("Request a username to register an account")
.AddOption("name", ApplicationCommandOptionType.String, "The username you want", true)
.Build();
SlashCommandProperties getCodeCommand = new SlashCommandBuilder()
.WithName(CMD_GET_CODE)
.WithDescription("Get the registration code for your approved username request")
.Build();
try
{
await botClient.CreateGlobalApplicationCommandAsync(infoCommand);
await botClient.CreateGlobalApplicationCommandAsync(broadcastCommand);
await botClient.CreateGlobalApplicationCommandAsync(queueDisableCommand);
await botClient.CreateGlobalApplicationCommandAsync(queueEnableCommand);
await botClient.CreateGlobalApplicationCommandAsync(requestNameCommand);
await botClient.CreateGlobalApplicationCommandAsync(getCodeCommand);
}
catch (HttpException exception)
{
@ -165,9 +186,150 @@ namespace CentralServer.LobbyServer.Discord
await command.RespondAsync("Matchmaking queue is unpaused", ephemeral: true);
break;
}
case CMD_REQUEST_NAME:
{
await HandleRequestName(command, handle);
break;
}
case CMD_GET_CODE:
{
await HandleGetCode(command, handle);
break;
}
}
}
private async Task<bool> IsWrongChannel(SocketSlashCommand command)
{
if (requestChannelId.HasValue && command.ChannelId != requestChannelId)
{
await command.RespondAsync(
$"Please use this command in <#{requestChannelId}>.",
ephemeral: true);
return true;
}
return false;
}
private async Task HandleRequestName(SocketSlashCommand command, string handle)
{
if (await IsWrongChannel(command))
{
return;
}
string name = command.Data.Options.First().Value.ToString()?.Trim() ?? "";
log.Info($"CMD /{command.Data.Name} - {handle}: {name}");
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
// One active request/code per Discord user.
RegistrationCodeDao.RegistrationCodeEntry existing = dao.FindLatestByDiscordUser(command.User.Id);
if (existing is { State: RegistrationCodeDao.RegistrationState.Requested })
{
await command.RespondAsync(
$"You already have a pending request for `{existing.IssuedTo}`. Please wait for it to be reviewed.",
ephemeral: true);
return;
}
if (existing is { State: RegistrationCodeDao.RegistrationState.Issued, IsValid: true })
{
await command.RespondAsync(
$"You already have an approved code waiting. Use `/{CMD_GET_CODE}` to receive it.",
ephemeral: true);
return;
}
if (!LoginManager.IsValidUsername(name))
{
await command.RespondAsync(LoginManager.InvalidUsername, ephemeral: true);
return;
}
if (!LoginManager.IsAllowedUsername(name))
{
await command.RespondAsync(LoginManager.CannotUseThisUsername, ephemeral: true);
return;
}
if (DB.Get().LoginDao.Find(name.ToLower()) is not null)
{
await command.RespondAsync(LoginManager.UsernameIsAlreadyUsed, ephemeral: true);
return;
}
SocketGuildUser guildUser = command.User as SocketGuildUser;
dao.Save(new RegistrationCodeDao.RegistrationCodeEntry
{
Code = Guid.NewGuid().ToString(),
State = RegistrationCodeDao.RegistrationState.Requested,
IssuedTo = name.ToLower(),
RequestedAt = DateTime.UtcNow,
DiscordUserId = command.User.Id,
DiscordUserName = command.User.Username,
DiscordDisplayName = guildUser?.DisplayName ?? command.User.Username,
DiscordAvatarUrl = command.User.GetAvatarUrl() ?? command.User.GetDefaultAvatarUrl(),
DiscordCreatedAt = command.User.CreatedAt.UtcDateTime,
DiscordJoinedAt = guildUser?.JoinedAt?.UtcDateTime
});
await command.RespondAsync(
$"Your request for `{name}` has been submitted for review. " +
"You will be pinged here once it is approved.",
ephemeral: true);
}
private async Task HandleGetCode(SocketSlashCommand command, string handle)
{
if (await IsWrongChannel(command))
{
return;
}
log.Info($"CMD /{command.Data.Name} - {handle}");
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
RegistrationCodeDao.RegistrationCodeEntry entry = dao.FindLatestByDiscordUser(command.User.Id);
if (entry is null || entry.State == RegistrationCodeDao.RegistrationState.Requested)
{
await command.RespondAsync(
$"You do not have an approved code yet. Use `/{CMD_REQUEST_NAME}` first, then wait for approval.",
ephemeral: true);
return;
}
if (entry.State == RegistrationCodeDao.RegistrationState.Declined)
{
await command.RespondAsync(
$"Your username request was declined: {entry.DeclineReason}",
ephemeral: true);
return;
}
if (entry.IsUsed)
{
await command.RespondAsync("You have already registered an account.", ephemeral: true);
return;
}
if (entry.HasExpired)
{
// Re-queue the request so an admin can approve it again.
entry.State = RegistrationCodeDao.RegistrationState.Requested;
entry.ExpiresAt = default;
entry.RequestedAt = DateTime.UtcNow;
dao.Save(entry);
await command.RespondAsync(
"Your registration code has expired. Your request has been sent back for review.",
ephemeral: true);
return;
}
await command.RespondAsync(
$"Your registration code for `{entry.IssuedTo}` is:\n`{entry.Code}`\n" +
"Enter this username and code on the registration screen.",
ephemeral: true);
}
private static Task Log(LogMessage msg)
{
return DiscordUtils.Log(log, msg);
@ -191,5 +353,40 @@ namespace CentralServer.LobbyServer.Discord
IMessageChannel chnl = botClient.GetChannel(_channelId.Value) as IMessageChannel;
return chnl.SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags);
}
private async Task PingRequestChannel(ulong discordUserId, string message)
{
if (!requestChannelId.HasValue)
{
return;
}
try
{
await SendMessageAsync(
text: message,
allowedMentions: new AllowedMentions(AllowedMentionTypes.Users),
channelIdOverride: requestChannelId);
}
catch (Exception e)
{
log.Error($"Failed to ping user {discordUserId} in the request channel", e);
}
}
public async Task PingUsernameRequestApproved(ulong discordUserId, string username)
{
await PingRequestChannel(
discordUserId,
$"<@{discordUserId}> your username request for `{username}` has been approved! " +
$"Use `/{CMD_GET_CODE}` to receive your registration code.");
}
public async Task PingUsernameRequestDeclined(ulong discordUserId, string reason)
{
await PingRequestChannel(
discordUserId,
$"<@{discordUserId}> your username request has been declined: {reason}");
}
}
}

View file

@ -18,9 +18,6 @@ namespace CentralServer.LobbyServer.Discord
public DiscordChannel AdminActionLogChannel;
public DiscordChannel AdminErrorLogChannel;
public string BotToken = "";
public ulong? BotChannelId;
public bool AdminEnableUserReports;
public ulong? AdminUserReportThreadId;
public bool AdminEnableChatAudit;

View file

@ -53,6 +53,8 @@ namespace CentralServer.LobbyServer.Discord
{ totalPlayers = -1, inGame = -1, inQueue = -1 };
private DiscordLobbyUtils.Status lastStatus = NO_STATUS;
public DiscordBotWrapper Bot => discordBot;
public DiscordManager()
{
@ -143,21 +145,21 @@ namespace CentralServer.LobbyServer.Discord
return;
}
if (conf.BotToken.IsNullOrEmpty())
DiscordBotConfiguration botConf = DiscordBotConfiguration.Get();
if (!botConf.Enabled || botConf.BotToken.IsNullOrEmpty())
{
log.Info("Discord bot is not enabled");
return;
}
if (conf.BotToken.Length < 70)
if (botConf.BotToken.Length < 70)
{
log.Error("Discord bot token is invalid");
return;
}
// Init bot but we dont use it for anything not yet anyway we just want chat from discord to atlas and commands
discordBot = new DiscordBotWrapper(conf);
await discordBot.Login(conf);
discordBot = new DiscordBotWrapper(botConf);
await discordBot.Login(botConf);
}
public void Shutdown()

19
Tests/LoginManagerTest.cs Normal file
View file

@ -0,0 +1,19 @@
using EvoS.DirectoryServer.Account;
namespace Tests;
public class LoginManagerTest
{
[Theory]
[InlineData("abcd", true)] // 4 chars, minimum
[InlineData("abc", false)] // 3 chars, too short
[InlineData("abcdefghijklmnopqrstuvwx", true)] // 24 chars, maximum
[InlineData("abcdefghijklmnopqrstuvwxy", false)] // 25 chars, too long
[InlineData("1abc", false)] // must start with a letter
[InlineData("ab cd", false)] // no spaces
[InlineData("ab-c_1", true)] // dash, underscore, digit allowed
public void IsValidUsername_EnforcesLengthAndCharset(string username, bool expected)
{
Assert.Equal(expected, LoginManager.IsValidUsername(username));
}
}

View file

@ -1,7 +1,13 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
LinearProgress,
Stack,
Table,
TableBody,
TableCell,
@ -11,13 +17,24 @@ import {
Tooltip,
Typography
} from "@mui/material";
import {formatDate, getRegistrationCodes, issueRegistrationCode, RegistrationCodeEntry} from "../../lib/Evos";
import React, {useEffect, useState} from "react";
import {
confirmUsernameRequest,
declineUsernameRequest,
formatDate,
formatRelativeTime,
getRegistrationCodes,
getUsernameRequests,
issueRegistrationCode,
RegistrationCodeEntry,
UsernameRequestEntry
} from "../../lib/Evos";
import React, {useCallback, useEffect, useState} from "react";
import {useAuthHeader} from "react-auth-kit";
import {useNavigate} from "react-router-dom";
import {EvosError, processError} from "../../lib/Error";
import BaseDialog from "../generic/BaseDialog";
import {EvosCard, FlexBox, plainAccountLink} from "../generic/BasicComponents";
import DiscordUser from "../generic/DiscordUser";
export default function IssueRegistrationCode() {
const [code, setCode] = useState<string>();
@ -25,9 +42,19 @@ export default function IssueRegistrationCode() {
const [codes, setCodes] = useState<RegistrationCodeEntry[]>();
const [codesBefore, setCodesBefore] = useState<Date>(new Date());
const [error, setError] = useState<EvosError>();
const [requests, setRequests] = useState<UsernameRequestEntry[]>();
const [requestsError, setRequestsError] = useState<EvosError>();
const [requestsRefresh, setRequestsRefresh] = useState<number>(0);
const [declineTarget, setDeclineTarget] = useState<UsernameRequestEntry>();
const [declineReason, setDeclineReason] = useState<string>("");
const authHeader = useAuthHeader()();
const navigate = useNavigate();
const refresh = useCallback(() => {
setRequestsRefresh(x => x + 1);
setCodesBefore(new Date(new Date().getTime() + 60000));
}, []);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
@ -54,13 +81,72 @@ export default function IssueRegistrationCode() {
const abort = new AbortController();
getRegistrationCodes(abort, authHeader, codesBefore)
.then((resp) => {
setError(undefined);
setCodes(resp.data.entries);
})
.catch((error) => processError(error, setError, navigate));
.catch((error) => {
if (abort.signal.aborted) {
return;
}
processError(error, setError, navigate);
});
return () => abort.abort();
}, [authHeader, navigate, codesBefore]);
useEffect(() => {
const abort = new AbortController();
getUsernameRequests(abort, authHeader)
.then((resp) => {
setRequestsError(undefined);
setRequests(resp.data.entries);
})
.catch((error) => {
if (abort.signal.aborted) {
return;
}
processError(error, setRequestsError, navigate);
});
return () => abort.abort();
}, [authHeader, navigate, requestsRefresh]);
const handleConfirm = (row: UsernameRequestEntry) => {
setProcessing(true);
const abort = new AbortController();
confirmUsernameRequest(abort, authHeader, row.code)
.catch(e => processError(e, setRequestsError, navigate))
.then(() => {
setProcessing(false);
refresh();
});
};
const submitDecline = () => {
if (!declineTarget) {
return;
}
setProcessing(true);
const abort = new AbortController();
declineUsernameRequest(abort, authHeader, declineTarget.code, declineReason)
.catch(e => processError(e, setRequestsError, navigate))
.then(() => {
setProcessing(false);
setDeclineTarget(undefined);
setDeclineReason("");
refresh();
});
};
const dateWithRelative = (ts: string) => (
<Box>
<div>{formatDate(ts)}</div>
<Typography variant="caption" color="text.secondary">
{formatRelativeTime(ts)}
</Typography>
</Box>
);
return <FlexBox style={{ flexDirection: 'column' }}>
<EvosCard variant="outlined">
<Box component="form" onSubmit={handleSubmit} noValidate style={{ padding: 4 }}>
@ -87,6 +173,90 @@ export default function IssueRegistrationCode() {
</Box>
</EvosCard>
<EvosCard variant="outlined" sx={{ maxWidth: 'none', width: '100%' }}>
<Typography variant="h6" sx={{ padding: 1 }}>Username requests</Typography>
{requestsError && <Typography sx={{ padding: 1 }}>
{`Failed to load requests: ${requestsError.text}${requestsError.description ? `(${requestsError.description})` : ""}`}
</Typography>}
{requests && requests.length === 0 && <Typography sx={{ padding: 1 }}>No pending requests.</Typography>}
{requests && requests.length > 0 &&
<Table>
<TableHead>
<TableRow>
<TableCell>Requested username</TableCell>
<TableCell>Discord user</TableCell>
<TableCell>Account created</TableCell>
<TableCell>Joined server</TableCell>
<TableCell>Requested at</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{requests.map((row) => (
<TableRow key={row.code}>
<TableCell>{row.requestedUsername}</TableCell>
<TableCell>
<DiscordUser
displayName={row.discordDisplayName}
userName={row.discordUserName}
userId={row.discordUserId}
avatarUrl={row.discordAvatarUrl}
/>
</TableCell>
<TableCell>{dateWithRelative(row.discordCreatedAt)}</TableCell>
<TableCell>{row.discordJoinedAt ? dateWithRelative(row.discordJoinedAt) : "-"}</TableCell>
<TableCell>{dateWithRelative(row.requestedAt)}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
<Button
disabled={processing}
variant="contained"
color="success"
size="small"
onClick={() => handleConfirm(row)}
>
Confirm
</Button>
<Button
disabled={processing}
variant="outlined"
color="error"
size="small"
onClick={() => setDeclineTarget(row)}
>
Decline
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
}
</EvosCard>
<Dialog open={declineTarget !== undefined} onClose={() => setDeclineTarget(undefined)} fullWidth>
<DialogTitle>Decline request for {declineTarget?.requestedUsername}</DialogTitle>
<DialogContent>
<DialogContentText>
This message will be sent to the requester on Discord.
</DialogContentText>
<TextField
autoFocus
margin="normal"
fullWidth
multiline
label="Reason"
value={declineReason}
onChange={(e) => setDeclineReason(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeclineTarget(undefined)}>Cancel</Button>
<Button color="error" disabled={processing} onClick={submitDecline}>Decline</Button>
</DialogActions>
</Dialog>
{error && <Typography>{`Failed to load codes: ${error.text}${error.description ? `(${error.description})` : ""}`}</Typography>}
{codes &&
<Box style={{ margin: "0 auto" }}>
@ -94,6 +264,7 @@ export default function IssueRegistrationCode() {
<TableHead>
<TableRow>
<TableCell>Issued to</TableCell>
<TableCell>Discord user</TableCell>
<TableCell>Issued by</TableCell>
<TableCell>Code</TableCell>
<TableCell>Issued at</TableCell>
@ -111,6 +282,14 @@ export default function IssueRegistrationCode() {
<TableCell>{claimed
? plainAccountLink(row.issuedTo, row.issuedToHandle, navigate)
: row.issuedToHandle}</TableCell>
<TableCell>
<DiscordUser
displayName={row.discordDisplayName}
userName={row.discordUserName}
userId={row.discordUserId}
avatarUrl={row.discordAvatarUrl}
/>
</TableCell>
<TableCell>{plainAccountLink(row.issuedBy, row.issuedByHandle, navigate)}</TableCell>
<TableCell>{claimed || expired
? <Tooltip title={claimed ? "Claimed" : "Expired"}><span style={{ textDecoration: "line-through"}}>{row.code}</span></Tooltip>
@ -125,4 +304,4 @@ export default function IssueRegistrationCode() {
</Box>
}
</FlexBox>;
}
}

View file

@ -0,0 +1,25 @@
import {Avatar, Box, Stack, Typography} from "@mui/material";
interface DiscordUserProps {
displayName?: string | null;
userName?: string | null;
userId?: string | null;
avatarUrl?: string | null;
}
export default function DiscordUser({displayName, userName, userId, avatarUrl}: DiscordUserProps) {
if (!userId) {
return <>-</>;
}
return (
<Stack direction="row" spacing={1} alignItems="center">
<Avatar src={avatarUrl ?? undefined} sx={{ width: 28, height: 28 }} />
<Box>
<div>{displayName}</div>
<Typography variant="caption" color="text.secondary">
{userName} · {userId}
</Typography>
</Box>
</Stack>
);
}

View file

@ -96,12 +96,32 @@ export interface RegistrationCodeEntry {
issuedAt: string;
expiresAt: string;
usedAt: string;
discordUserId: string | null;
discordUserName: string | null;
discordDisplayName: string | null;
discordAvatarUrl: string | null;
}
export interface RegistrationCodesResponse {
entries: RegistrationCodeEntry[];
}
export interface UsernameRequestEntry {
code: string;
requestedUsername: string;
discordUserId: string;
discordUserName: string;
discordDisplayName: string;
discordAvatarUrl: string;
discordCreatedAt: string;
discordJoinedAt: string | null;
requestedAt: string;
}
export interface UsernameRequestsResponse {
entries: UsernameRequestEntry[];
}
export interface SearchResults {
players: PlayerData[];
}
@ -226,6 +246,33 @@ export function formatDate(ts: string): string {
return ts ? new Date(ts).toLocaleString() : "N/A";
}
export function formatRelativeTime(ts: string): string {
if (!ts) {
return "";
}
const seconds = Math.max(0, (new Date().getTime() - new Date(ts).getTime()) / 1000);
const units: [number, string][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.34524, "week"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
];
let value = seconds;
let unit = "second";
for (const [size, name] of units) {
unit = name;
if (value < size) {
break;
}
value /= size;
}
const rounded = Math.floor(value);
return `${rounded} ${unit}${rounded === 1 ? "" : "s"} ago`;
}
export function cap(txt: string): string {
return txt.charAt(0).toUpperCase() + txt.slice(1);
}
@ -352,6 +399,26 @@ export function getRegistrationCodes(abort: AbortController, authHeader: string,
{ params: { before: Math.floor(before.getTime() / 1000) }, headers: { 'Authorization': authHeader }, signal: abort.signal });
}
export function getUsernameRequests(abort: AbortController, authHeader: string) {
return axios.get<UsernameRequestsResponse>(
baseUrl + "/api/admin/player/usernameRequests",
{ headers: { 'Authorization': authHeader }, signal: abort.signal });
}
export function confirmUsernameRequest(abort: AbortController, authHeader: string, code: string) {
return axios.post(
baseUrl + "/api/admin/player/usernameRequest/confirm",
{ code: code },
{ headers: { 'Authorization': authHeader }, signal: abort.signal });
}
export function declineUsernameRequest(abort: AbortController, authHeader: string, code: string, reason: string) {
return axios.post(
baseUrl + "/api/admin/player/usernameRequest/decline",
{ code: code, reason: reason },
{ headers: { 'Authorization': authHeader }, signal: abort.signal });
}
export function generateTempPassword(abort: AbortController, authHeader: string, accountId: number) {
return axios.post<RegistrationCodeResponse>(
baseUrl + "/api/admin/player/generateTempPassword",