Discord authentification (#819)
* Auth setup * fixes * messages * adapt to new auth * cvar fix * generating link setup * new version adapt * fix * Update DiscordAuthManager.cs
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using Content.Client._CP14.Discord;
|
||||
using Content.Client.Administration.Managers;
|
||||
using Content.Client.Changelog;
|
||||
using Content.Client.Chat.Managers;
|
||||
@@ -43,6 +44,9 @@ namespace Content.Client.Entry
|
||||
{
|
||||
public sealed class EntryPoint : GameClient
|
||||
{
|
||||
//CP14
|
||||
[Dependency] private readonly DiscordAuthManager _discordAuth = default!;
|
||||
//CP14 end
|
||||
[Dependency] private readonly IBaseClient _baseClient = default!;
|
||||
[Dependency] private readonly IGameController _gameController = default!;
|
||||
[Dependency] private readonly IStateManager _stateManager = default!;
|
||||
@@ -160,7 +164,10 @@ namespace Content.Client.Entry
|
||||
|
||||
_parallaxManager.LoadDefaultParallax();
|
||||
|
||||
_overlayManager.AddOverlay(new CP14BasePostProcessOverlay()); // CP14-PostProcess
|
||||
//CP14
|
||||
_overlayManager.AddOverlay(new CP14BasePostProcessOverlay());
|
||||
_discordAuth.Initialize();
|
||||
//CP14 end
|
||||
_overlayManager.AddOverlay(new SingularityOverlay());
|
||||
_overlayManager.AddOverlay(new RadiationPulseOverlay());
|
||||
_chatManager.Initialize();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Content.Client._CP14.Discord;
|
||||
using Content.Client.Administration.Managers;
|
||||
using Content.Client.Changelog;
|
||||
using Content.Client.Chat.Managers;
|
||||
@@ -33,6 +34,9 @@ namespace Content.Client.IoC
|
||||
{
|
||||
var collection = IoCManager.Instance!;
|
||||
|
||||
//CP14
|
||||
collection.Register<DiscordAuthManager>();
|
||||
//CP14 end
|
||||
collection.Register<IParallaxManager, ParallaxManager>();
|
||||
collection.Register<IChatManager, ChatManager>();
|
||||
collection.Register<ISharedChatManager, ChatManager>();
|
||||
|
||||
27
Content.Client/_CP14/Discord/CP14DiscordAuthManager.cs
Normal file
27
Content.Client/_CP14/Discord/CP14DiscordAuthManager.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Content.Shared._CP14.Discord;
|
||||
using Robust.Client.State;
|
||||
using Robust.Shared.Network;
|
||||
|
||||
namespace Content.Client._CP14.Discord;
|
||||
|
||||
public sealed class DiscordAuthManager
|
||||
{
|
||||
[Dependency] private readonly IClientNetManager _netManager = default!;
|
||||
[Dependency] private readonly IStateManager _stateManager = default!;
|
||||
|
||||
public string AuthUrl { get; private set; } = "";
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_netManager.RegisterNetMessage<MsgDiscordAuthCheck>();
|
||||
_netManager.RegisterNetMessage<MsgDiscordAuthRequired>(OnDiscordAuthRequired);
|
||||
}
|
||||
|
||||
private void OnDiscordAuthRequired(MsgDiscordAuthRequired msg)
|
||||
{
|
||||
if (_stateManager.CurrentState is DiscordAuthState)
|
||||
return;
|
||||
AuthUrl = msg.AuthUrl;
|
||||
_stateManager.RequestStateChange<DiscordAuthState>();
|
||||
}
|
||||
}
|
||||
36
Content.Client/_CP14/Discord/CP14DiscordAuthState.cs
Normal file
36
Content.Client/_CP14/Discord/CP14DiscordAuthState.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Threading;
|
||||
using Content.Shared._CP14.Discord;
|
||||
using Robust.Client.State;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Shared.Network;
|
||||
using Timer = Robust.Shared.Timing.Timer;
|
||||
|
||||
namespace Content.Client._CP14.Discord;
|
||||
|
||||
public sealed class DiscordAuthState : State
|
||||
{
|
||||
[Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!;
|
||||
[Dependency] private readonly IClientNetManager _netManager = default!;
|
||||
|
||||
private DiscordAuthGui? _gui;
|
||||
private readonly CancellationTokenSource _checkTimerCancel = new();
|
||||
|
||||
protected override void Startup()
|
||||
{
|
||||
_gui = new DiscordAuthGui();
|
||||
_userInterfaceManager.StateRoot.AddChild(_gui);
|
||||
|
||||
Timer.SpawnRepeating(TimeSpan.FromSeconds(5),
|
||||
() =>
|
||||
{
|
||||
_netManager.ClientSendMessage(new MsgDiscordAuthCheck());
|
||||
},
|
||||
_checkTimerCancel.Token);
|
||||
}
|
||||
|
||||
protected override void Shutdown()
|
||||
{
|
||||
_checkTimerCancel.Cancel();
|
||||
_gui!.Dispose();
|
||||
}
|
||||
}
|
||||
29
Content.Client/_CP14/Discord/DiscordAuthGui.xaml
Normal file
29
Content.Client/_CP14/Discord/DiscordAuthGui.xaml
Normal file
@@ -0,0 +1,29 @@
|
||||
<Control xmlns="https://spacestation14.io"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:parallax="clr-namespace:Content.Client.Parallax"
|
||||
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls">
|
||||
<parallax:ParallaxControl />
|
||||
<Control HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<PanelContainer StyleClasses="AngleRect" />
|
||||
<BoxContainer Orientation="Vertical">
|
||||
<BoxContainer Orientation="Horizontal">
|
||||
<Label Margin="8 0 0 0" Text="{Loc 'cp14-discord-auth-title'}"
|
||||
StyleClasses="LabelHeading" VAlign="Center" />
|
||||
<Button Name="QuitButton" Text="{Loc 'cp14-discord-auth-quit-btn'}"
|
||||
HorizontalAlignment="Right" HorizontalExpand="True" />
|
||||
</BoxContainer>
|
||||
<controls:HighDivider />
|
||||
<BoxContainer Orientation="Vertical" Margin="50 20 50 20">
|
||||
<RichTextLabel Name="InfoLabel" />
|
||||
</BoxContainer>
|
||||
<BoxContainer HorizontalExpand="True">
|
||||
<LineEdit Editable="False" Name="AuthLinkEdit" HorizontalExpand="True" />
|
||||
<LineEdit Editable="False" Name="DLinkEdit" HorizontalExpand="True" />
|
||||
</BoxContainer>
|
||||
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
|
||||
<Button Name="AuthorizeButton" Text="{Loc 'cp14-discord-auth-text'}" HorizontalExpand="True" StyleClasses="OpenRight" />
|
||||
<Button Name="DiscordButton" Text="{Loc 'cp14-discord-auth-browser-btn'}" HorizontalExpand="True" StyleClasses="OpenRight" />
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</Control>
|
||||
</Control>
|
||||
46
Content.Client/_CP14/Discord/DiscordAuthGui.xaml.cs
Normal file
46
Content.Client/_CP14/Discord/DiscordAuthGui.xaml.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.Console;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
|
||||
namespace Content.Client._CP14.Discord;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class DiscordAuthGui : Control
|
||||
{
|
||||
[Dependency] private readonly IClientConsoleHost _consoleHost = default!;
|
||||
[Dependency] private readonly DiscordAuthManager _discordAuthManager = default!;
|
||||
|
||||
private const string DiscordLink = "https://discord.com/invite/Sud2DMfhCC"; //TODO: Unhardcode
|
||||
|
||||
public DiscordAuthGui()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
IoCManager.InjectDependencies(this);
|
||||
LayoutContainer.SetAnchorPreset(this, LayoutContainer.LayoutPreset.Wide);
|
||||
|
||||
var link = _discordAuthManager.AuthUrl;
|
||||
|
||||
AuthLinkEdit.SetText(link);
|
||||
DLinkEdit.SetText(DiscordLink);
|
||||
InfoLabel.SetMessage(Loc.GetString("cp14-discord-info"));
|
||||
|
||||
var uriOpener = IoCManager.Resolve<IUriOpener>();
|
||||
|
||||
QuitButton.OnPressed += _ =>
|
||||
{
|
||||
_consoleHost.ExecuteCommand("quit");
|
||||
};
|
||||
|
||||
AuthorizeButton.OnPressed += _ =>
|
||||
{
|
||||
uriOpener.OpenUri(link);
|
||||
};
|
||||
|
||||
DiscordButton.OnPressed += _ =>
|
||||
{
|
||||
uriOpener.OpenUri(DiscordLink);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Content.Server._CP14.Discord;
|
||||
using Content.Server.Acz;
|
||||
using Content.Server.Administration;
|
||||
using Content.Server.Administration.Logs;
|
||||
@@ -101,6 +102,10 @@ namespace Content.Server.Entry
|
||||
logManager.GetSawmill("Storage").Level = LogLevel.Info;
|
||||
logManager.GetSawmill("db.ef").Level = LogLevel.Info;
|
||||
|
||||
//CP14
|
||||
IoCManager.Resolve<DiscordAuthManager>().Initialize();
|
||||
//CP14 end
|
||||
|
||||
IoCManager.Resolve<IAdminLogManager>().Initialize();
|
||||
IoCManager.Resolve<IConnectionManager>().Initialize();
|
||||
_dbManager.Init();
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace Content.Server.GameTicking
|
||||
|
||||
// Make the player actually join the game.
|
||||
// timer time must be > tick length
|
||||
Timer.Spawn(0, () => _playerManager.JoinGame(args.Session));
|
||||
//Timer.Spawn(0, () => _playerManager.JoinGame(args.Session)); //CP14 Discord AuthManager
|
||||
|
||||
var record = await _db.GetPlayerRecordByUserId(args.Session.UserId);
|
||||
var firstConnection = record != null &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Content.Server._CP14.Discord;
|
||||
using Content.Server.Administration;
|
||||
using Content.Server.Administration.Logs;
|
||||
using Content.Server.Administration.Managers;
|
||||
@@ -36,6 +37,9 @@ namespace Content.Server.IoC
|
||||
{
|
||||
public static void Register()
|
||||
{
|
||||
//CP14
|
||||
IoCManager.Register<DiscordAuthManager>();
|
||||
//CP14 end
|
||||
IoCManager.Register<IChatManager, ChatManager>();
|
||||
IoCManager.Register<ISharedChatManager, ChatManager>();
|
||||
IoCManager.Register<IChatSanitizationManager, ChatSanitizationManager>();
|
||||
|
||||
138
Content.Server/_CP14/Discord/DiscordAuthManager.cs
Normal file
138
Content.Server/_CP14/Discord/DiscordAuthManager.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Content.Shared._CP14.Discord;
|
||||
using Content.Shared.CCVar;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Enums;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Player;
|
||||
using Timer = Robust.Shared.Timing.Timer;
|
||||
|
||||
namespace Content.Server._CP14.Discord;
|
||||
|
||||
public sealed class DiscordAuthManager
|
||||
{
|
||||
[Dependency] private readonly IServerNetManager _netMgr = default!;
|
||||
[Dependency] private readonly IPlayerManager _playerMgr = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
private readonly HttpClient _httpClient = new();
|
||||
private bool _enabled = false;
|
||||
private string _apiUrl = string.Empty;
|
||||
private string _apiKey = string.Empty;
|
||||
|
||||
public event EventHandler<ICommonSession>? PlayerVerified;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_sawmill = Logger.GetSawmill("discordAuth");
|
||||
|
||||
_cfg.OnValueChanged(CCVars.DiscordAuthEnabled, v => _enabled = v, true);
|
||||
_cfg.OnValueChanged(CCVars.DiscordAuthUrl, v => _apiUrl = v, true);
|
||||
_cfg.OnValueChanged(CCVars.DiscordAuthToken, v => _apiKey = v, true);
|
||||
|
||||
_netMgr.RegisterNetMessage<MsgDiscordAuthRequired>();
|
||||
_netMgr.RegisterNetMessage<MsgDiscordAuthCheck>(OnAuthCheck);
|
||||
|
||||
_playerMgr.PlayerStatusChanged += OnPlayerStatusChanged;
|
||||
PlayerVerified += OnPlayerVerified;
|
||||
}
|
||||
|
||||
private void OnPlayerVerified(object? obj, ICommonSession session)
|
||||
{
|
||||
Timer.Spawn(0, () => _playerMgr.JoinGame(session));
|
||||
}
|
||||
|
||||
private async void OnAuthCheck(MsgDiscordAuthCheck msg)
|
||||
{
|
||||
var verified = await IsVerified(msg.MsgChannel.UserId);
|
||||
if (!verified)
|
||||
return;
|
||||
|
||||
var session = _playerMgr.GetSessionById(msg.MsgChannel.UserId);
|
||||
PlayerVerified?.Invoke(this, session);
|
||||
}
|
||||
|
||||
private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs args)
|
||||
{
|
||||
if (args.NewStatus != SessionStatus.Connected)
|
||||
return;
|
||||
|
||||
if (!_enabled)
|
||||
{
|
||||
PlayerVerified?.Invoke(this, args.Session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewStatus == SessionStatus.Connected)
|
||||
{
|
||||
var verified = await IsVerified(args.Session.UserId);
|
||||
if (verified)
|
||||
{
|
||||
PlayerVerified?.Invoke(this, args.Session);
|
||||
return;
|
||||
}
|
||||
|
||||
var message = new MsgDiscordAuthRequired();
|
||||
message.AuthUrl = await GenerateLink(args.Session.UserId) ?? string.Empty;
|
||||
args.Session.Channel.SendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsVerified(NetUserId userId, CancellationToken cancel = default)
|
||||
{
|
||||
_sawmill.Debug($"Player {userId} check Discord verification");
|
||||
|
||||
var requestUrl = $"{_apiUrl}/api/uuid?method=uid&id={userId}";
|
||||
_sawmill.Debug($"Auth request url:{requestUrl}");
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancel);
|
||||
|
||||
_sawmill.Debug($"{await response.Content.ReadAsStringAsync(cancel)}");
|
||||
_sawmill.Debug($"{(int) response.StatusCode}");
|
||||
return response.StatusCode == HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
public async Task<string?> GenerateLink(NetUserId userId, CancellationToken cancel = default)
|
||||
{
|
||||
_sawmill.Debug($"Generating link for {userId}");
|
||||
var requestUrl = $"{_apiUrl}/api/link?uid={userId}";
|
||||
|
||||
// try catch block to catch HttpRequestExceptions due to remote service unavailability
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(requestUrl, cancel);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var link = await response.Content.ReadFromJsonAsync<DiscordLinkResponse>(cancel);
|
||||
return link!.Link;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
_sawmill.Error("Remote auth service is unreachable. Check if its online!");
|
||||
return null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_sawmill.Error($"Unexpected error verifying user via auth service. Error: {e.Message}. Stack: \n{e.StackTrace}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class DiscordLinkResponse
|
||||
{
|
||||
[JsonPropertyName("link")]
|
||||
public string Link { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
15
Content.Shared/_CP14/CCvar/CCvars.CP14DiscordAuth.cs
Normal file
15
Content.Shared/_CP14/CCvar/CCvars.CP14DiscordAuth.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Robust.Shared.Configuration;
|
||||
|
||||
namespace Content.Shared.CCVar;
|
||||
|
||||
public sealed partial class CCVars
|
||||
{
|
||||
public static readonly CVarDef<bool> DiscordAuthEnabled =
|
||||
CVarDef.Create("cp14.discord_auth_enabled", false, CVar.SERVERONLY);
|
||||
|
||||
public static readonly CVarDef<string> DiscordAuthUrl =
|
||||
CVarDef.Create("cp14.discord_auth_url", "http://localhost:8000/sponsors", CVar.SERVERONLY | CVar.CONFIDENTIAL);
|
||||
|
||||
public static readonly CVarDef<string> DiscordAuthToken =
|
||||
CVarDef.Create("cp14.discord_auth_token", "token", CVar.SERVERONLY | CVar.CONFIDENTIAL);
|
||||
}
|
||||
9
Content.Shared/_CP14/CCvar/CCvars.CP14JoinQueue.cs
Normal file
9
Content.Shared/_CP14/CCvar/CCvars.CP14JoinQueue.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Robust.Shared.Configuration;
|
||||
|
||||
namespace Content.Shared.CCVar;
|
||||
|
||||
public sealed partial class CCVars
|
||||
{
|
||||
public static readonly CVarDef<bool> QueueEnabled =
|
||||
CVarDef.Create("cp14.join_queue_enabled", true, CVar.SERVERONLY);
|
||||
}
|
||||
12
Content.Shared/_CP14/CCvar/CCvars.CP14Sponsors.cs
Normal file
12
Content.Shared/_CP14/CCvar/CCvars.CP14Sponsors.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Robust.Shared.Configuration;
|
||||
|
||||
namespace Content.Shared.CCVar;
|
||||
|
||||
public sealed partial class CCVars
|
||||
{
|
||||
public static readonly CVarDef<string> SponsorsApiUrl =
|
||||
CVarDef.Create("cp14.sponsor_api_url", "http://localhost:8000/sponsors", CVar.SERVERONLY | CVar.CONFIDENTIAL);
|
||||
|
||||
public static readonly CVarDef<string> SponsorsApiKey =
|
||||
CVarDef.Create("cp14.sponsor_api_key", "token", CVar.SERVERONLY | CVar.CONFIDENTIAL);
|
||||
}
|
||||
18
Content.Shared/_CP14/Discord/MsgDiscordAuthCheck.cs
Normal file
18
Content.Shared/_CP14/Discord/MsgDiscordAuthCheck.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Lidgren.Network;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Serialization;
|
||||
|
||||
namespace Content.Shared._CP14.Discord;
|
||||
|
||||
public sealed class MsgDiscordAuthCheck : NetMessage
|
||||
{
|
||||
public override MsgGroups MsgGroup => MsgGroups.Command;
|
||||
|
||||
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
|
||||
{
|
||||
}
|
||||
|
||||
public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
|
||||
{
|
||||
}
|
||||
}
|
||||
21
Content.Shared/_CP14/Discord/MsgDiscordAuthRequired.cs
Normal file
21
Content.Shared/_CP14/Discord/MsgDiscordAuthRequired.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Lidgren.Network;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Serialization;
|
||||
|
||||
namespace Content.Shared._CP14.Discord;
|
||||
|
||||
public sealed class MsgDiscordAuthRequired : NetMessage
|
||||
{
|
||||
public override MsgGroups MsgGroup => MsgGroups.Command;
|
||||
public string AuthUrl { get; set; } = string.Empty;
|
||||
|
||||
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
|
||||
{
|
||||
AuthUrl = buffer.ReadString();
|
||||
}
|
||||
|
||||
public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
|
||||
{
|
||||
buffer.Write(AuthUrl);
|
||||
}
|
||||
}
|
||||
5
Resources/Locale/en-US/_CP14/connection_messages.ftl
Normal file
5
Resources/Locale/en-US/_CP14/connection_messages.ftl
Normal file
@@ -0,0 +1,5 @@
|
||||
stalker-discord-info = To play on the server you need to go to our server in discord to authorise your account.
|
||||
stalker-discord-auth-quit-btn = Exit
|
||||
stalker-discord-auth-title = Authorisation
|
||||
stalker-discord-auth-link = https://discord.com/invite/Sud2DMfhCC
|
||||
stalker-discord-auth-browser-btn = Discord Server
|
||||
5
Resources/Locale/en-US/_CP14/discord/queue.ftl
Normal file
5
Resources/Locale/en-US/_CP14/discord/queue.ftl
Normal file
@@ -0,0 +1,5 @@
|
||||
queue-title = Queue
|
||||
queue-quit = Disconnect
|
||||
queue-position = Position
|
||||
queue-total = Total
|
||||
queue-priority-join = Priority join
|
||||
6
Resources/Locale/ru-RU/_CP14/connection_messages.ftl
Normal file
6
Resources/Locale/ru-RU/_CP14/connection_messages.ftl
Normal file
@@ -0,0 +1,6 @@
|
||||
cp14-discord-info = Для игры на сервере вам необходимо пройти на наш сервер в дискорд для авторизации вашего аккаунта.
|
||||
cp14-discord-auth-quit-btn = Выход
|
||||
cp14-discord-auth-title = Авторизация
|
||||
cp14-discord-auth-link = https://discord.com/invite/Sud2DMfhCC
|
||||
cp14-discord-auth-browser-btn = Сервер Discord
|
||||
cp14-discord-auth-text = Авторизоваться
|
||||
5
Resources/Locale/ru-RU/_CP14/discord/queue.ftl
Normal file
5
Resources/Locale/ru-RU/_CP14/discord/queue.ftl
Normal file
@@ -0,0 +1,5 @@
|
||||
queue-title = Очередь
|
||||
queue-quit = Отключиться
|
||||
queue-position = Позиция
|
||||
queue-total = Всего
|
||||
queue-priority-join = Приоритетный вход
|
||||
Reference in New Issue
Block a user