Compare commits

...

46 Commits

Author SHA1 Message Date
Anna ce73a18374
chore: bump version to 1.3.0 2022-08-27 13:51:05 -04:00
Anna 6f340836d9
chore: reject feedback 2022-08-27 13:51:05 -04:00
Anna e91a446361
feat: begin adding filtering support 2022-08-27 12:31:12 -04:00
Anna 500687194c
feat: only update players on login 2022-08-27 11:46:17 -04:00
Anna 8cd95f7b48
chore: bump version to 1.2.3 2022-08-24 20:55:02 -04:00
Anna 685c3570d4
fix: go back to simple message handling 2022-08-24 20:54:19 -04:00
Anna 168c299e0c
chore: bump version to 1.2.2 2022-08-24 19:31:08 -04:00
Anna 12c81bdceb
chore: bump version to 1.2.1 2022-08-20 17:03:36 -04:00
Anna 58b4def737
chore: update for net6 and api7 2022-08-20 17:03:27 -04:00
Anna c4d062af69
fix: update logic for showing world 2022-08-14 02:03:05 -04:00
Anna e3adfaf545
fix: update oldest first 2022-08-13 18:01:40 -04:00
Anna 4d7988d136
fix: don't interrupt login if error getting character info 2022-08-13 09:56:44 -04:00
Anna 5c2cd8a2ce
feat: output debug message for long-running updates 2022-08-13 09:34:17 -04:00
Anna 6ab4384a7f
feat: use a shared memory pool for reading 2022-07-31 19:47:23 -04:00
Anna 5027362621
feat: handle new logins for same user 2022-07-30 10:46:47 -04:00
Anna 5121bb67e9
feat: bump max receive size to 1 MiB 2022-07-30 00:37:06 -04:00
Anna fc4a57efcd
chore: bump version to 1.2.0 2022-07-19 21:22:54 -04:00
Anna 52a4fd2a9f
fiX: don't always open 2022-07-19 19:47:00 -04:00
Anna abe467e69b
feat: put settings in trees 2022-07-19 19:45:05 -04:00
Anna d91eaefe99
fix: set status to not authenticated on delete 2022-07-19 19:01:08 -04:00
Anna d3dfd18ab2
fix: gate delete account on being connected 2022-07-19 19:00:02 -04:00
Anna 4a3fab32e0
fix: use id for delete button 2022-07-19 18:58:35 -04:00
Anna eb3a0c659d
fix: wrap text 2022-07-19 18:47:40 -04:00
Anna b0dba5bd55
fix: send auth message with correct invite permissions 2022-07-19 18:43:52 -04:00
Anna 5b9d4a5121
feat: add delete account and invite permissions to plugin 2022-07-19 18:42:44 -04:00
Anna e9d9bab0da
feat: add delete account message 2022-07-19 18:19:55 -04:00
Anna 6f34c0ac38
refactor: make redacted inner element private 2022-07-19 18:12:11 -04:00
Anna ce6149be04
chore: remove commented dependency 2022-07-19 18:09:30 -04:00
Anna 748d63a8d2
feat: add option to disable invites 2022-07-19 18:09:23 -04:00
Anna d8ce434aea
chore: clarify disclaimer text 2022-07-19 18:08:26 -04:00
Anna f4b4acae52
refactor: update dependencies 2022-07-18 17:32:42 -04:00
Anna 27746a8744
chore: update description 2022-07-18 17:28:19 -04:00
Anna b55bffce7a
chore: add icon 2022-07-18 17:27:02 -04:00
Anna bab1044a04
chore: bump version to 1.1.4 2022-07-18 17:21:53 -04:00
Anna 8682ffdfec
refactor: use helper method for encrypting ls name 2022-07-18 17:14:51 -04:00
Anna 4d8dbf50b7
refactor: replace mutex with semaphore 2022-07-18 17:13:14 -04:00
Anna 8344fe4a68
feat: add context menu option 2022-07-18 16:59:52 -04:00
Anna 456c942fa0
refactor: pull channel list into its own class 2022-07-18 16:07:44 -04:00
Anna ace08c13fe
feat: add more influxdb measurements 2022-07-18 12:51:17 -04:00
Anna 5173f3cb49
fix: request channel info on window open 2022-07-16 02:34:24 -04:00
Anna 77322ccbeb
chore: bump version to 1.1.3 2022-07-14 13:41:38 -04:00
Anna 2026a33ff6
fix: account for invited channels 2022-07-14 13:41:21 -04:00
Anna e8e9206f10
feat: add influxdb reporting 2022-07-14 13:40:43 -04:00
Anna 30853e15ba
chore: bump version to 1.1.2 2022-07-13 16:36:05 -04:00
Anna 08a3e3a02e
fix: catch ipc errors 2022-07-13 16:35:47 -04:00
Anna d16eb7f084
chore: bump version to 1.1.1 2022-07-13 12:41:42 -04:00
38 changed files with 1855 additions and 528 deletions

View File

@ -42,7 +42,7 @@ internal class Client : IDisposable {
private KeyPair KeyPair { get; }
private readonly Mutex _waitersMutex = new();
private readonly SemaphoreSlim _waitersSemaphore = new(1, 1);
private Dictionary<uint, ChannelWriter<ResponseKind>> Waiters { get; set; } = new();
private Channel<(RequestContainer, ChannelWriter<ChannelReader<ResponseKind>>?)> ToSend { get; set; } = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter<ChannelReader<ResponseKind>>?)>();
@ -69,6 +69,7 @@ internal class Client : IDisposable {
this._active = false;
this.WebSocket.Dispose();
this._waitersSemaphore.Dispose();
}
private void Login(object? sender, EventArgs e) {
@ -112,11 +113,15 @@ internal class Client : IDisposable {
});
}
private ChannelReader<ResponseKind> RegisterWaiter(uint number) {
private async Task<ChannelReader<ResponseKind>> RegisterWaiter(uint number) {
var channel = System.Threading.Channels.Channel.CreateBounded<ResponseKind>(1);
this._waitersMutex.WaitOne();
this.Waiters[number] = channel.Writer;
this._waitersMutex.ReleaseMutex();
await this._waitersSemaphore.WaitAsync();
try {
this.Waiters[number] = channel.Writer;
} finally {
this._waitersSemaphore.Release();
}
return channel.Reader;
}
@ -201,9 +206,7 @@ internal class Client : IDisposable {
internal async Task<(Channel, byte[])> Create(string name) {
var shared = SodiumSecretBoxXChaCha20Poly1305.GenerateKey();
var nonce = SodiumSecretBoxXChaCha20Poly1305.GenerateNonce();
var ciphertext = SodiumSecretBoxXChaCha20Poly1305.Create(Encoding.UTF8.GetBytes(name), nonce, shared);
var encryptedName = nonce.Concat(ciphertext);
var encryptedName = SecretBox.Encrypt(shared, Encoding.UTF8.GetBytes(name));
var response = await this.QueueMessageAndWait(new RequestKind.Create(new CreateRequest {
Name = encryptedName,
@ -286,6 +289,28 @@ internal class Client : IDisposable {
}
}
internal async Task<string?> DeleteAccount() {
var response = await this.QueueMessageAndWait(new RequestKind.DeleteAccount(new DeleteAccountRequest()));
return response switch {
ResponseKind.Error { Response.Error: var error } => error,
ResponseKind.DeleteAccount => null,
_ => throw new Exception("Unexpected response"),
};
}
internal async Task DeleteAccountToast() {
var message = await this.DeleteAccount();
if (message != null) {
this.Plugin.ShowError($"Could not delete account: {message}");
return;
}
this.Plugin.Config.Configs.Remove(this.Plugin.ClientState.LocalContentId);
this.Plugin.SaveConfig();
this.StopLoop();
this.Status = State.NotAuthenticated;
}
/// <summary>
/// Attempts to register the user after the challenge has been completed.
/// </summary>
@ -334,6 +359,7 @@ internal class Client : IDisposable {
var response = await this.QueueMessageAndWait(new RequestKind.Authenticate(new AuthenticateRequest {
Key = key,
PublicKey = this.KeyPair.GetPublicKey(),
AllowInvites = this.Plugin.ConfigInfo.AllowInvites,
}));
var success = response switch {
@ -485,6 +511,21 @@ internal class Client : IDisposable {
}));
}
internal async Task<bool> AllowInvites(bool allow) {
var resp = await this.QueueMessageAndWait(new RequestKind.AllowInvites(new AllowInvitesRequest {
Allowed = allow,
}));
return resp is ResponseKind.AllowInvites { Response.Allowed: var respAllowed } && respAllowed == allow;
}
internal async Task AllowInvitesToast(bool allow) {
if (!await this.AllowInvites(allow)) {
this.Plugin.ShowError("Could not set invite permissions.");
}
}
private bool _up;
#pragma warning disable CS4014
@ -500,10 +541,17 @@ internal class Client : IDisposable {
return;
}
this.Channels.Clear();
this.InvitedChannels.Clear();
this.ChannelRanks.Clear();
this.Waiters.Clear();
this.ToSend = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter<ChannelReader<ResponseKind>>?)>();
this._waitersMutex.WaitOne();
this.Waiters = new Dictionary<uint, ChannelWriter<ResponseKind>>();
this._waitersMutex.ReleaseMutex();
await this._waitersSemaphore.WaitAsync();
try {
this.Waiters = new Dictionary<uint, ChannelWriter<ResponseKind>>();
} finally {
this._waitersSemaphore.Release();
}
// If the websocket is closed, we need to reconnect
this.WebSocket.Dispose();
@ -585,13 +633,13 @@ internal class Client : IDisposable {
break;
}
default: {
this._waitersMutex.WaitOne();
await this._waitersSemaphore.WaitAsync();
try {
if (this.Waiters.Remove(response.Number, out var waiter)) {
await waiter.WriteAsync(response.Kind);
}
} finally {
this._waitersMutex.ReleaseMutex();
this._waitersSemaphore.Release();
}
break;
@ -603,7 +651,7 @@ internal class Client : IDisposable {
await this.WebSocket.SendMessage(req);
if (update != null) {
await update.WriteAsync(this.RegisterWaiter(req.Number));
await update.WriteAsync(await this.RegisterWaiter(req.Number));
}
}
}
@ -888,6 +936,7 @@ internal class Client : IDisposable {
}
this.Plugin.Commands.ReregisterAll();
this.Plugin.Ipc.BroadcastChannelNames();
}
private void HandleMessage(MessageResponse resp) {
@ -900,6 +949,9 @@ internal class Client : IDisposable {
var message = SeString.Parse(SecretBox.Decrypt(info.SharedSecret, resp.Message));
var output = new SeStringBuilder();
// add a tag payload for filtering
output.Add(PayloadUtil.CreateTagPayload(resp.Channel));
output.Add(RawPayload.LinkTerminator);
var colour = config.GetUiColour(resp.Channel);
output.AddUiForeground(colour);
@ -915,7 +967,9 @@ internal class Client : IDisposable {
output.Add(new PlayerPayload(resp.Sender, resp.World));
}
if (!isSelf && resp.World != this.Plugin.LocalPlayer?.CurrentWorld.Id) {
var homeWorldsSame = resp.World == this.Plugin.LocalPlayer?.HomeWorld.Id;
var homeWorldsSameAndOnHomeWorld = homeWorldsSame && this.Plugin.LocalPlayer?.CurrentWorld.Id == resp.World;
if (!isSelf && !homeWorldsSameAndOnHomeWorld) {
output.AddIcon(BitmapFontIcon.CrossWorld);
var world = this.Plugin.DataManager.GetExcelSheet<World>()?.GetRow(resp.World)?.Name.ToDalamudString();
if (world != null) {

View File

@ -11,6 +11,7 @@ internal class Configuration : IPluginConfiguration {
public int Version { get; set; } = 1;
public bool UseNativeToasts = true;
public bool ShowContextMenuItem = true;
public XivChatType DefaultChannel = XivChatType.Debug;
public Dictionary<ulong, ConfigInfo> Configs { get; } = new();
@ -41,6 +42,7 @@ internal class ConfigInfo {
public Dictionary<Guid, string> ChannelMarkers = new();
public Dictionary<Guid, XivChatType> ChannelChannels = new();
public int TutorialStep;
public bool AllowInvites = true;
internal string GetName(Guid id) => this.Channels.TryGetValue(id, out var channel)
? channel.Name

35
client/ExtraChat/ExtraChat.csproj Normal file → Executable file
View File

@ -1,64 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.1.0</Version>
<TargetFramework>net5.0-windows</TargetFramework>
<Version>1.3.0</Version>
<TargetFramework>net6.0-windows</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<DebugType>full</DebugType>
<DebugSymbols>true</DebugSymbols>
</PropertyGroup>
<PropertyGroup>
<Dalamud>$(AppData)\XIVLauncher\addon\Hooks\dev</Dalamud>
<DalamudLibPath>$(AppData)\XIVLauncher\addon\Hooks\dev</DalamudLibPath>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
<Dalamud>$(DALAMUD_HOME)</Dalamud>
<DalamudLibPath>$(DALAMUD_HOME)</DalamudLibPath>
</PropertyGroup>
<PropertyGroup Condition="'$(IsCI)' == 'true'">
<Dalamud>$(HOME)/dalamud</Dalamud>
<DalamudLibPath>$(HOME)/dalamud</DalamudLibPath>
</PropertyGroup>
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>$(Dalamud)\Dalamud.dll</HintPath>
<HintPath>$(DalamudLibPath)\Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(Dalamud)\FFXIVClientStructs.dll</HintPath>
<HintPath>$(DalamudLibPath)\FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(Dalamud)\ImGui.NET.dll</HintPath>
<HintPath>$(DalamudLibPath)\ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(Dalamud)\ImGuiScene.dll</HintPath>
<HintPath>$(DalamudLibPath)\ImGuiScene.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(Dalamud)\Lumina.dll</HintPath>
<HintPath>$(DalamudLibPath)\Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(Dalamud)\Lumina.Excel.dll</HintPath>
<HintPath>$(DalamudLibPath)\Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="ASodium" Version="0.5.3" />
<PackageReference Include="Dalamud.ContextMenu" Version="1.0.0" />
<PackageReference Include="DalamudPackager" Version="2.1.7" />
<PackageReference Include="MessagePack" Version="2.3.112" />
<PackageReference Include="System.Net.WebSockets.Client" Version="4.3.2" />
<PackageReference Include="System.Threading.Channels" Version="6.0.0" />
<PackageReference Include="ASodium" Version="0.5.3"/>
<PackageReference Include="Dalamud.ContextMenu" Version="1.2.1"/>
<PackageReference Include="DalamudPackager" Version="2.1.8"/>
<PackageReference Include="MessagePack" Version="2.4.35"/>
<PackageReference Include="System.Net.WebSockets.Client" Version="4.3.2"/>
<PackageReference Include="System.Threading.Channels" Version="6.0.0"/>
</ItemGroup>
</Project>

View File

@ -1,6 +1,9 @@
name: ExtraChat
author: ascclemens
description: |-
It's more chat. Very alpha. /ecl# is the LS command.
punchline: '[ALPHA] Cross-data-centre linkshells with unlimited members.'
ExtraChat adds end-to-end encrypted, cross-data-centre, unlimited linkshells
to the game.
punchline: 'Cross-data-centre linkshells with unlimited members.'
repo_url: https://git.annaclemens.io/ascclemens/ExtraChat
accepts_feedback: false
feedback_message: Submit only bugs to https://github.com/ascclemens/plugin-issues

View File

@ -25,6 +25,8 @@ public class RequestKindFormatter : IMessagePackFormatter<RequestKind> {
RequestKind.Promote => "promote",
RequestKind.Update => "update",
RequestKind.Version => "version",
RequestKind.DeleteAccount => "delete_account",
RequestKind.AllowInvites => "allow_invites",
_ => throw new ArgumentOutOfRangeException(nameof(value)),
};
@ -82,6 +84,12 @@ public class RequestKindFormatter : IMessagePackFormatter<RequestKind> {
case RequestKind.Version version:
options.Resolver.GetFormatterWithVerify<VersionRequest>().Serialize(ref writer, version.Request, options);
break;
case RequestKind.DeleteAccount deleteAccount:
options.Resolver.GetFormatterWithVerify<DeleteAccountRequest>().Serialize(ref writer, deleteAccount.Request, options);
break;
case RequestKind.AllowInvites allowInvites:
options.Resolver.GetFormatterWithVerify<AllowInvitesRequest>().Serialize(ref writer, allowInvites.Request, options);
break;
}
}
@ -94,7 +102,7 @@ public class RequestKindFormatter : IMessagePackFormatter<RequestKind> {
switch (key) {
case "ping": {
var request = MessagePackSerializer.Deserialize<PingRequest>(ref reader, options);
var request = options.Resolver.GetFormatterWithVerify<PingRequest>().Deserialize(ref reader, options);
return new RequestKind.Ping(request);
}
case "authenticate": {
@ -161,6 +169,14 @@ public class RequestKindFormatter : IMessagePackFormatter<RequestKind> {
var request = options.Resolver.GetFormatterWithVerify<VersionRequest>().Deserialize(ref reader, options);
return new RequestKind.Version(request);
}
case "delete_account": {
var request = options.Resolver.GetFormatterWithVerify<DeleteAccountRequest>().Deserialize(ref reader, options);
return new RequestKind.DeleteAccount(request);
}
case "allow_invites": {
var request = options.Resolver.GetFormatterWithVerify<AllowInvitesRequest>().Deserialize(ref reader, options);
return new RequestKind.AllowInvites(request);
}
default:
throw new MessagePackSerializationException("Invalid RequestKind");
}

View File

@ -106,6 +106,14 @@ public class ResponseKindFormatter : IMessagePackFormatter<ResponseKind> {
var response = options.Resolver.GetFormatterWithVerify<AnnounceResponse>().Deserialize(ref reader, options);
return new ResponseKind.Announce(response);
}
case "delete_account": {
var response = options.Resolver.GetFormatterWithVerify<DeleteAccountResponse>().Deserialize(ref reader, options);
return new ResponseKind.DeleteAccount(response);
}
case "allow_invites": {
var response = options.Resolver.GetFormatterWithVerify<AllowInvitesResponse>().Deserialize(ref reader, options);
return new ResponseKind.AllowInvites(response);
}
default:
throw new MessagePackSerializationException("Invalid ResponseKind");
}

View File

@ -24,13 +24,24 @@ internal class ChatTwo : IDisposable {
this.Available = this.Plugin.Interface.GetIpcSubscriber<object?>("ChatTwo.Available");
this.Available.Subscribe(this.DoRegister);
this.DoRegister();
try {
this.DoRegister();
} catch (Exception) {
// try to register if chat 2 is already loaded
// if not, just ignore exception
}
this.Invoke.Subscribe(this.Integration);
}
public void Dispose() {
if (this._id != null) {
this.Unregister.InvokeAction(this._id);
try {
this.Unregister.InvokeAction(this._id);
} catch (Exception) {
// no-op
}
this._id = null;
}

View File

@ -14,17 +14,21 @@ internal class Ipc : IDisposable {
private Plugin Plugin { get; }
private ICallGateProvider<OverrideInfo, object> OverrideChannelColour { get; }
private ICallGateProvider<Dictionary<string, uint>, Dictionary<string, uint>> ChannelCommandColours { get; }
private ICallGateProvider<Dictionary<Guid, string>, Dictionary<Guid, string>> ChannelNames { get; }
internal Ipc(Plugin plugin) {
this.Plugin = plugin;
this.OverrideChannelColour = this.Plugin.Interface.GetIpcProvider<OverrideInfo, object>("ExtraChat.OverrideChannelColour");
this.ChannelCommandColours = this.Plugin.Interface.GetIpcProvider<Dictionary<string, uint>, Dictionary<string, uint>>("ExtraChat.ChannelCommandColours");
this.ChannelNames = this.Plugin.Interface.GetIpcProvider<Dictionary<Guid, string>, Dictionary<Guid, string>>("ExtraChat.ChannelNames");
this.ChannelCommandColours.RegisterFunc(_ => this.GetChannelColours());
this.ChannelNames.RegisterFunc(_ => this.GetChannelNames());
}
public void Dispose() {
this.ChannelNames.UnregisterFunc();
this.ChannelCommandColours.UnregisterFunc();
}
@ -41,10 +45,23 @@ internal class Ipc : IDisposable {
return dict;
}
private Dictionary<Guid, string> GetChannelNames() {
return this.Plugin.Client.Channels
.Values
.ToDictionary(
channel => channel.Id,
channel => this.Plugin.ConfigInfo.Channels.TryGetValue(channel.Id, out var info) ? info.Name : "???"
);
}
internal void BroadcastChannelCommandColours() {
this.ChannelCommandColours.SendMessage(this.GetChannelColours());
}
internal void BroadcastChannelNames() {
this.ChannelNames.SendMessage(this.GetChannelNames());
}
internal void BroadcastOverride() {
var over = this.Plugin.GameFunctions.OverrideChannel;
if (over == Guid.Empty) {

View File

@ -60,7 +60,7 @@ public class Plugin : IDalamudPlugin {
internal Client Client { get; }
internal Commands Commands { get; }
internal PluginUi PluginUi { get; }
internal DalamudContextMenuBase ContextMenu { get; }
internal DalamudContextMenu ContextMenu { get; }
internal GameFunctions GameFunctions { get; }
internal Ipc Ipc { get; }
private IDisposable[] Integrations { get; }
@ -85,7 +85,7 @@ public class Plugin : IDalamudPlugin {
public Plugin() {
SodiumInit.Init();
WorldUtil.Initialise(this.DataManager!);
this.ContextMenu = new DalamudContextMenuBase();
this.ContextMenu = new DalamudContextMenu();
this.Config = this.Interface!.GetPluginConfig() as Configuration ?? new Configuration();
this.Client = new Client(this);
this.Commands = new Commands(this);
@ -98,13 +98,13 @@ public class Plugin : IDalamudPlugin {
};
this.Framework!.Update += this.FrameworkUpdate;
this.ContextMenu.Functions.ContextMenu.OnOpenGameObjectContextMenu += this.OnOpenGameObjectContextMenu;
this.ContextMenu.OnOpenGameObjectContextMenu += this.OnOpenGameObjectContextMenu;
}
public void Dispose() {
this.GameFunctions.ResetOverride();
this.ContextMenu.Functions.ContextMenu.OnOpenGameObjectContextMenu -= this.OnOpenGameObjectContextMenu;
this.ContextMenu.OnOpenGameObjectContextMenu -= this.OnOpenGameObjectContextMenu;
this.Framework.Update -= this.FrameworkUpdate;
this._localPlayerLock.Dispose();
@ -130,6 +130,10 @@ public class Plugin : IDalamudPlugin {
}
private void OnOpenGameObjectContextMenu(GameObjectContextMenuOpenArgs args) {
if (!this.Config.ShowContextMenuItem) {
return;
}
if (args.ObjectId != 0xE0000000) {
this.ObjectContext(args);
return;

View File

@ -0,0 +1,17 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class AllowInvitesRequest {
[Key(0)]
public bool Allowed;
}
[Serializable]
[MessagePackObject]
public class AllowInvitesResponse {
[Key(0)]
public bool Allowed;
}

View File

@ -2,6 +2,7 @@ using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class AnnounceResponse {
[Key(0)]

View File

@ -10,4 +10,7 @@ public class AuthenticateRequest {
[Key(1)]
public byte[] PublicKey;
[Key(2)]
public bool AllowInvites;
}

View File

@ -0,0 +1,8 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class DeleteAccountRequest {
}

View File

@ -0,0 +1,8 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class DeleteAccountResponse {
}

View File

@ -57,4 +57,10 @@ public abstract record RequestKind {
[MessagePackObject]
public record Version(VersionRequest Request) : RequestKind;
[MessagePackObject]
public record DeleteAccount(DeleteAccountRequest Request) : RequestKind;
[MessagePackObject]
public record AllowInvites(AllowInvitesRequest Request) : RequestKind;
}

View File

@ -72,4 +72,10 @@ public abstract record ResponseKind {
[MessagePackObject]
public record Announce(AnnounceResponse Response) : ResponseKind;
[MessagePackObject]
public record DeleteAccount(DeleteAccountResponse Response) : ResponseKind;
[MessagePackObject]
public record AllowInvites(AllowInvitesResponse Response) : ResponseKind;
}

View File

@ -0,0 +1,405 @@
using System.Numerics;
using System.Text;
using Dalamud.Interface;
using ExtraChat.Protocol;
using ExtraChat.Protocol.Channels;
using ExtraChat.Util;
using ImGuiNET;
using Lumina.Excel.GeneratedSheets;
namespace ExtraChat.Ui;
internal class ChannelList {
private Plugin Plugin { get; }
private readonly List<(string, List<World>)> _worlds;
private string _createName = string.Empty;
private Guid _selectedChannel = Guid.Empty;
private string _inviteName = string.Empty;
private ushort _inviteWorld;
private string _rename = string.Empty;
internal ChannelList(Plugin plugin) {
this.Plugin = plugin;
this._worlds = this.Plugin.DataManager.GetExcelSheet<World>()!
.Where(row => row.IsPublic)
.GroupBy(row => row.DataCenter.Value!)
.Where(grouping => grouping.Key.Region != 0)
.OrderBy(grouping => grouping.Key.Region)
.ThenBy(grouping => grouping.Key.Name.RawString)
.Select(grouping => (grouping.Key.Name.RawString, grouping.OrderBy(row => row.Name.RawString).ToList()))
.ToList();
}
internal void Draw() {
var anyChanged = false;
ImGui.PushFont(UiBuilder.IconFont);
var syncButton = ImGui.CalcTextSize(FontAwesomeIcon.Sync.ToIconString()).X
+ ImGui.GetStyle().FramePadding.X * 2;
// PluginLog.Log($"syncButton: {syncButton}");
var addButton = ImGui.CalcTextSize(FontAwesomeIcon.Plus.ToIconString()).X
+ ImGui.GetStyle().FramePadding.X * 2;
// PluginLog.Log($"addButton: {addButton}");
var syncOffset = ImGui.GetContentRegionAvail().X - syncButton;
var addOffset = ImGui.GetContentRegionAvail().X - syncButton - ImGui.GetStyle().ItemSpacing.X - addButton;
ImGui.SameLine(syncOffset);
if (ImGui.Button(FontAwesomeIcon.Sync.ToIconString())) {
Task.Run(async () => await this.Plugin.Client.ListAll());
}
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 1);
ImGui.SameLine(addOffset);
if (ImGui.Button(FontAwesomeIcon.Plus.ToIconString())) {
ImGui.OpenPopup("create-channel-popup");
}
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 0);
ImGui.PopFont();
if (ImGui.BeginPopup("create-channel-popup")) {
ImGui.TextUnformatted("Create a new ExtraChat Linkshell");
ImGui.SetNextItemWidth(350 * ImGuiHelpers.GlobalScale);
ImGui.InputTextWithHint("##linkshell-name", "Linkshell name", ref this._createName, 64);
if (ImGui.IsWindowAppearing()) {
ImGui.SetKeyboardFocusHere(-1);
}
if (!string.IsNullOrWhiteSpace(this._createName) && ImGui.Button("Create") && !this.Plugin.PluginUi.Busy) {
this.Plugin.PluginUi.Busy = true;
var name = this._createName;
Task.Run(async () => await this.Plugin.Client.Create(name))
.ContinueWith(_ => this.Plugin.PluginUi.Busy = false);
ImGui.CloseCurrentPopup();
this._createName = string.Empty;
}
ImGui.EndPopup();
}
if (this.Plugin.Client.Channels.Count == 0 && this.Plugin.Client.InvitedChannels.Count == 0) {
ImGui.TextUnformatted("You aren't in any linkshells yet. Try creating or joining one first.");
goto AfterTable;
}
this.DrawTable(ref anyChanged);
AfterTable:
if (anyChanged) {
this.Plugin.SaveConfig();
}
}
private void DrawTable(ref bool anyChanged) {
if (!ImGui.BeginTable("ecls-list", 2, ImGuiTableFlags.Resizable | ImGuiTableFlags.SizingFixedFit)) {
return;
}
ImGui.TableSetupColumn("##channels", ImGuiTableColumnFlags.WidthFixed, 125 * ImGuiHelpers.GlobalScale);
ImGui.TableSetupColumn("##members", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableNextRow();
var channelOrder = this.Plugin.ConfigInfo.ChannelOrder.ToDictionary(
entry => entry.Value,
entry => entry.Key
);
var orderedChannels = this.Plugin.Client.Channels.Keys
.OrderBy(id => channelOrder.ContainsKey(id) ? channelOrder[id] : int.MaxValue)
.Concat(this.Plugin.Client.InvitedChannels.Keys);
var childSize = new Vector2(
-1,
ImGui.GetContentRegionAvail().Y
- ImGui.GetStyle().WindowPadding.Y
- ImGui.GetStyle().ItemSpacing.Y
);
if (ImGui.TableSetColumnIndex(0)) {
this.DrawChannelList(ref anyChanged, childSize, orderedChannels, channelOrder);
}
if (ImGui.TableSetColumnIndex(1) && this._selectedChannel != Guid.Empty) {
if (ImGui.IsWindowAppearing()) {
Task.Run(async () => await this.Plugin.Client.ListMembers(this._selectedChannel));
}
if (ImGui.BeginChild("channel-info", childSize)) {
this.DrawInfo(ref anyChanged);
ImGui.EndChild();
}
}
ImGui.EndTable();
}
private void DrawChannelList(ref bool anyChanged, Vector2 childSize, IEnumerable<Guid> orderedChannels, Dictionary<Guid, int> channelOrder) {
if (!ImGui.BeginChild("channel-list", childSize)) {
return;
}
var first = true;
foreach (var id in orderedChannels) {
this.DrawChannel(ref anyChanged, channelOrder, id, ref first);
}
ImGui.EndChild();
}
private void DrawChannel(ref bool anyChanged, IReadOnlyDictionary<Guid, int> channelOrder, Guid id, ref bool first) {
this.Plugin.ConfigInfo.Channels.TryGetValue(id, out var info);
var name = info?.Name ?? "???";
var order = "?";
if (channelOrder.TryGetValue(id, out var o)) {
order = (o + 1).ToString();
}
if (!this.Plugin.Client.ChannelRanks.TryGetValue(id, out var rank)) {
rank = Rank.Member;
}
if (ImGui.Selectable($"{order}. {rank.Symbol()}{name}###{id}", this._selectedChannel == id)) {
this._selectedChannel = id;
Task.Run(async () => await this.Plugin.Client.ListMembers(id));
}
if (first) {
first = false;
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 2);
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 3);
}
if (!ImGui.BeginPopupContextItem()) {
return;
}
var invited = this.Plugin.Client.InvitedChannels.ContainsKey(id);
if (invited) {
if (ImGui.Selectable("Accept invite")) {
Task.Run(async () => await this.Plugin.Client.Join(id));
}
if (ImGuiUtil.SelectableConfirm("Decline invite")) {
Task.Run(async () => await this.Plugin.Client.Leave(id));
}
} else {
if (ImGuiUtil.SelectableConfirm("Leave")) {
Task.Run(async () => await this.Plugin.Client.Leave(id));
}
if (rank == Rank.Admin) {
if (ImGuiUtil.SelectableConfirm("Disband")) {
Task.Run(async () => {
if (await this.Plugin.Client.Disband(id) is { } error) {
this.Plugin.ShowError($"Could not disband \"{name}\": {error}");
}
});
}
}
if (rank == Rank.Admin && info != null && ImGui.BeginMenu($"Rename##{id}-rename")) {
if (ImGui.IsWindowAppearing()) {
this._rename = string.Empty;
}
ImGui.SetNextItemWidth(350 * ImGuiHelpers.GlobalScale);
ImGui.InputTextWithHint($"##{id}-rename-input", "New name", ref this._rename, 64);
if (ImGui.IsWindowAppearing()) {
ImGui.SetKeyboardFocusHere(-1);
}
if (ImGui.Button($"Rename##{id}-rename-button") && !string.IsNullOrWhiteSpace(this._rename)) {
var newName = SecretBox.Encrypt(info.SharedSecret, Encoding.UTF8.GetBytes(this._rename));
Task.Run(async () => await this.Plugin.Client.UpdateToast(id, new UpdateKind.Name(newName)));
ImGui.CloseCurrentPopup();
}
ImGui.EndMenu();
}
if (ImGui.BeginMenu($"Invite##{id}-invite")) {
if (ImGui.IsWindowAppearing()) {
this._inviteName = string.Empty;
this._inviteWorld = 0;
}
ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale);
ImGui.InputTextWithHint("##invite-name", "Name", ref this._inviteName, 32);
ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale);
var preview = this._inviteWorld == 0 ? "World" : WorldUtil.WorldName(this._inviteWorld);
if (ImGui.BeginCombo("##invite-world", preview)) {
foreach (var (dc, worlds) in this._worlds) {
ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]);
ImGui.TextUnformatted(dc);
ImGui.PopStyleColor();
ImGui.Separator();
foreach (var world in worlds) {
if (ImGui.Selectable(world.Name.RawString, this._inviteWorld == world.RowId)) {
this._inviteWorld = (ushort) world.RowId;
}
}
ImGui.Spacing();
}
ImGui.EndCombo();
}
if (ImGui.Button($"Invite##{id}-invite-button") && !string.IsNullOrWhiteSpace(this._inviteName) && this._inviteWorld != 0) {
var inviteName = this._inviteName;
var inviteWorld = this._inviteWorld;
Task.Run(async () => await this.Plugin.Client.InviteToast(inviteName, inviteWorld, id));
}
ImGui.EndMenu();
}
ImGui.Separator();
if (ImGui.BeginMenu("Change number")) {
ImGui.SetNextItemWidth(150 * ImGuiHelpers.GlobalScale);
channelOrder.TryGetValue(id, out var refOrder);
var old = refOrder;
refOrder += 1;
if (ImGui.InputInt($"##{id}-order", ref refOrder)) {
refOrder = Math.Max(1, refOrder) - 1;
if (this.Plugin.ConfigInfo.ChannelOrder.TryGetValue(refOrder, out var other) && other != id) {
// another channel already has this number, so swap
this.Plugin.ConfigInfo.ChannelOrder[old] = other;
} else {
this.Plugin.ConfigInfo.ChannelOrder.Remove(old);
}
this.Plugin.ConfigInfo.ChannelOrder[refOrder] = id;
this.Plugin.SaveConfig();
this.Plugin.Commands.ReregisterAll();
}
ImGui.EndMenu();
}
if (info == null) {
if (ImGui.Selectable("Request secrets")) {
Task.Run(async () => await this.Plugin.Client.RequestSecrets(id));
}
}
}
ImGui.EndPopup();
}
private void DrawInfo(ref bool anyChanged) {
if (!this.Plugin.Client.TryGetChannel(this._selectedChannel, out var channel)) {
return;
}
Vector4 disabledColour;
unsafe {
disabledColour = *ImGui.GetStyleColorVec4(ImGuiCol.TextDisabled);
}
if (!this.Plugin.Client.ChannelRanks.TryGetValue(this._selectedChannel, out var rank)) {
rank = Rank.Member;
}
var first = true;
foreach (var member in channel.Members) {
if (!member.Online) {
ImGui.PushStyleColor(ImGuiCol.Text, disabledColour);
}
try {
ImGui.TextUnformatted($"{member.Rank.Symbol()}{member.Name}{PluginUi.CrossWorld}{WorldUtil.WorldName(member.World)}");
} finally {
if (!member.Online) {
ImGui.PopStyleColor();
}
}
this.DrawMemberContextMenu(member, rank);
if (!first) {
continue;
}
first = false;
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 4);
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 5);
}
}
private void DrawMemberContextMenu(Member member, Rank rank) {
if (!ImGui.BeginPopupContextItem($"{this._selectedChannel}-{member.Name}@{member.World}-context")) {
return;
}
var cursor = ImGui.GetCursorPos();
if (rank == Rank.Admin) {
if (member.Rank is not (Rank.Admin or Rank.Invited)) {
if (ImGuiUtil.SelectableConfirm("Promote to admin", tooltip: "This will demote you to moderator.")) {
Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Admin));
}
}
if (member.Rank == Rank.Moderator && ImGuiUtil.SelectableConfirm("Demote")) {
Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Member));
}
if (member.Rank == Rank.Member && ImGuiUtil.SelectableConfirm("Promote to moderator")) {
Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Moderator));
}
}
if (rank >= Rank.Moderator) {
var canKick = member.Rank < rank && member.Rank != Rank.Invited;
if (canKick && ImGuiUtil.SelectableConfirm("Kick")) {
Task.Run(async () => {
if (await this.Plugin.Client.Kick(this._selectedChannel, member.Name, member.World) is { } error) {
this.Plugin.ShowError($"Could not kick {member.Name}: {error}");
}
});
}
if (member.Rank == Rank.Invited && ImGuiUtil.SelectableConfirm("Cancel invite")) {
Task.Run(async () => await this.Plugin.Client.Kick(this._selectedChannel, member.Name, member.World));
}
}
if (rank == Rank.Invited && member.Rank == Rank.Invited) {
if (member.Name == this.Plugin.LocalPlayer?.Name.TextValue && member.World == this.Plugin.LocalPlayer?.HomeWorld.Id) {
if (ImGui.Selectable("Accept invite")) {
Task.Run(async () => await this.Plugin.Client.Join(this._selectedChannel));
}
if (ImGuiUtil.SelectableConfirm("Decline invite")) {
Task.Run(async () => await this.Plugin.Client.Leave(this._selectedChannel));
}
}
}
if (cursor == ImGui.GetCursorPos()) {
ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]);
ImGui.TextUnformatted("No options available");
ImGui.PopStyleColor();
}
ImGui.EndPopup();
}
}

View File

@ -1,11 +1,9 @@
using System.Diagnostics;
using System.Numerics;
using System.Text;
using System.Threading.Channels;
using Dalamud;
using Dalamud.Interface;
using Dalamud.Plugin;
using ExtraChat.Protocol;
using ExtraChat.Protocol.Channels;
using ExtraChat.Util;
using ImGuiNET;
@ -18,23 +16,15 @@ internal class PluginUi : IDisposable {
internal const string CrossWorld = "\ue05d";
private Plugin Plugin { get; }
private ChannelList ChannelList { get; }
internal bool Visible;
private readonly List<(string, List<World>)> _worlds;
private readonly List<(uint Id, Vector4 Abgr)> _uiColours;
internal PluginUi(Plugin plugin) {
this.Plugin = plugin;
this._worlds = this.Plugin.DataManager.GetExcelSheet<World>()!
.Where(row => row.IsPublic)
.GroupBy(row => row.DataCenter.Value!)
.Where(grouping => grouping.Key.Region != 0)
.OrderBy(grouping => grouping.Key.Region)
.ThenBy(grouping => grouping.Key.Name.RawString)
.Select(grouping => (grouping.Key.Name.RawString, grouping.OrderBy(row => row.Name.RawString).ToList()))
.ToList();
this.ChannelList = new ChannelList(this.Plugin);
this._uiColours = this.Plugin.DataManager.GetExcelSheet<UIColor>()!
.Where(row => row.UIForeground is not (0 or 0x000000FF))
@ -66,9 +56,8 @@ internal class PluginUi : IDisposable {
internal (string, ushort)? InviteInfo;
private volatile bool _busy;
internal volatile bool Busy;
private string? _challenge;
private string _createName = string.Empty;
private Guid? _inviteId;
private readonly Channel<string?> _challengeChannel = Channel.CreateUnbounded<string?>();
@ -104,20 +93,20 @@ internal class PluginUi : IDisposable {
var status = this.Plugin.Client.Status;
ImGui.TextUnformatted($"Status: {status}");
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.Wifi, tooltip: "Reconnect") && !this._busy) {
this._busy = true;
if (ImGuiUtil.IconButton(FontAwesomeIcon.Wifi, tooltip: "Reconnect") && !this.Busy) {
this.Busy = true;
Task.Run(async () => {
this.Plugin.Client.StopLoop();
await Task.Delay(TimeSpan.FromSeconds(5));
this.Plugin.Client.StartLoop();
this._busy = false;
this.Busy = false;
});
}
switch (status) {
case Client.State.Connected:
this.DrawList();
this.ChannelList.Draw();
break;
case Client.State.NotAuthenticated:
case Client.State.RetrievingChallenge:
@ -182,6 +171,7 @@ internal class PluginUi : IDisposable {
private void DrawSettingsGeneral(ref bool anyChanged) {
anyChanged |= ImGui.Checkbox("Use native toasts", ref this.Plugin.Config.UseNativeToasts);
anyChanged |= ImGui.Checkbox("Add invite context menu item", ref this.Plugin.Config.ShowContextMenuItem);
// ImGui.Spacing();
//
// ImGui.TextUnformatted("Default channel");
@ -196,6 +186,35 @@ internal class PluginUi : IDisposable {
//
// ImGui.EndCombo();
// }
if (this.Plugin.LocalPlayer is { } player) {
if (ImGui.TreeNodeEx($"Settings for {player.Name}{CrossWorld}{player.HomeWorld.GameData?.Name}")) {
if (ImGui.Checkbox("Allow receiving invites", ref this.Plugin.ConfigInfo.AllowInvites)) {
anyChanged = true;
Task.Run(async () => await this.Plugin.Client.AllowInvitesToast(this.Plugin.ConfigInfo.AllowInvites));
}
ImGui.TreePop();
}
}
if (this.Plugin.Client.Status == Client.State.Connected && ImGui.TreeNodeEx("Delete account")) {
ImGui.PushTextWrapPos();
if (this.Plugin.Client.Channels.Count > 0) {
ImGui.TextUnformatted("You must leave or disband all ExtraChat linkshells you are currently in before you can delete your account.");
} else {
ImGui.TextUnformatted("Clicking the button below will permanently and irreversibly delete your account from ExtraChat's servers.");
if (ImGui.Button("Delete account##actual-delete")) {
Task.Run(async () => await this.Plugin.Client.DeleteAccountToast());
}
}
ImGui.PopTextWrapPos();
ImGui.TreePop();
}
}
private void DrawSettingsLinkshells(ref bool anyChanged) {
@ -394,18 +413,18 @@ internal class PluginUi : IDisposable {
if (this.Plugin.ConfigInfo.Key != null) {
ImGui.TextUnformatted("Please wait...");
} else {
if (ImGui.Button($"Register {player.Name}") && !this._busy) {
this._busy = true;
if (ImGui.Button($"Register {player.Name}") && !this.Busy) {
this.Busy = true;
Task.Run(async () => {
var challenge = await this.Plugin.Client.GetChallenge();
await this._challengeChannel.Writer.WriteAsync(challenge);
}).ContinueWith(_ => this._busy = false);
}).ContinueWith(_ => this.Busy = false);
}
ImGui.PushTextWrapPos();
ImGui.TextUnformatted("ExtraChat is a third-party service that allows for functionally unlimited extra linkshells that work across data centres.");
ImGui.TextUnformatted("In order to use ExtraChat, characters must be registered and verified using their Lodestone profile.");
ImGui.TextUnformatted("ExtraChat stores your character's name, home world, and Lodestone ID, as well as what linkshells your character is a part of and has been invited to.");
ImGui.TextUnformatted("ExtraChat stores your character's name, home world, and Lodestone ID, as well as what ExtraChat linkshells your character is a part of and has been invited to.");
ImGui.TextUnformatted("Messages and linkshell names are end-to-end encrypted; the server cannot decrypt them and does not store messages.");
ImGui.TextUnformatted("In the event of a legal subpoena, ExtraChat will provide any information available to the legal system.");
ImGui.PopTextWrapPos();
@ -451,363 +470,18 @@ internal class PluginUi : IDisposable {
ImGui.SameLine();
if (ImGui.Button("Verify") && !this._busy) {
this._busy = true;
if (ImGui.Button("Verify") && !this.Busy) {
this.Busy = true;
Task.Run(async () => {
var key = await this.Plugin.Client.Register();
this.Plugin.ConfigInfo.Key = key;
this.Plugin.SaveConfig();
await this.Plugin.Client.AuthenticateAndList();
}).ContinueWith(_ => this._busy = false);
}).ContinueWith(_ => this.Busy = false);
}
}
ImGui.PopTextWrapPos();
}
}
private Guid _selectedChannel = Guid.Empty;
private string _inviteName = string.Empty;
private ushort _inviteWorld;
private string _rename = string.Empty;
private void DrawList() {
var anyChanged = false;
ImGui.PushFont(UiBuilder.IconFont);
var syncButton = ImGui.CalcTextSize(FontAwesomeIcon.Sync.ToIconString()).X
+ ImGui.GetStyle().FramePadding.X * 2;
// PluginLog.Log($"syncButton: {syncButton}");
var addButton = ImGui.CalcTextSize(FontAwesomeIcon.Plus.ToIconString()).X
+ ImGui.GetStyle().FramePadding.X * 2;
// PluginLog.Log($"addButton: {addButton}");
var syncOffset = ImGui.GetContentRegionAvail().X - syncButton;
var addOffset = ImGui.GetContentRegionAvail().X - syncButton - ImGui.GetStyle().ItemSpacing.X - addButton;
ImGui.SameLine(syncOffset);
if (ImGui.Button(FontAwesomeIcon.Sync.ToIconString())) {
Task.Run(async () => await this.Plugin.Client.ListAll());
}
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 1);
ImGui.SameLine(addOffset);
if (ImGui.Button(FontAwesomeIcon.Plus.ToIconString())) {
ImGui.OpenPopup("create-channel-popup");
}
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 0);
ImGui.PopFont();
if (ImGui.BeginPopup("create-channel-popup")) {
ImGui.TextUnformatted("Create a new ExtraChat Linkshell");
ImGui.SetNextItemWidth(350 * ImGuiHelpers.GlobalScale);
ImGui.InputTextWithHint("##linkshell-name", "Linkshell name", ref this._createName, 64);
if (ImGui.IsWindowAppearing()) {
ImGui.SetKeyboardFocusHere(-1);
}
if (!string.IsNullOrWhiteSpace(this._createName) && ImGui.Button("Create") && !this._busy) {
this._busy = true;
var name = this._createName;
Task.Run(async () => await this.Plugin.Client.Create(name))
.ContinueWith(_ => this._busy = false);
ImGui.CloseCurrentPopup();
this._createName = string.Empty;
}
ImGui.EndPopup();
}
if (this.Plugin.Client.Channels.Count == 0) {
ImGui.TextUnformatted("You aren't in any linkshells yet. Try creating or joining one first.");
goto AfterTable;
}
if (ImGui.BeginTable("ecls-list", 2, ImGuiTableFlags.Resizable | ImGuiTableFlags.SizingFixedFit)) {
ImGui.TableSetupColumn("##channels", ImGuiTableColumnFlags.WidthFixed, 125 * ImGuiHelpers.GlobalScale);
ImGui.TableSetupColumn("##members", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableNextRow();
var channelOrder = this.Plugin.ConfigInfo.ChannelOrder.ToDictionary(
entry => entry.Value,
entry => entry.Key
);
var orderedChannels = this.Plugin.Client.Channels.Keys
.OrderBy(id => channelOrder.ContainsKey(id) ? channelOrder[id] : int.MaxValue)
.Concat(this.Plugin.Client.InvitedChannels.Keys);
var childSize = new Vector2(
-1,
ImGui.GetContentRegionAvail().Y
- ImGui.GetStyle().WindowPadding.Y
- ImGui.GetStyle().ItemSpacing.Y
);
if (ImGui.TableSetColumnIndex(0)) {
if (ImGui.BeginChild("channel-list", childSize)) {
var first = true;
foreach (var id in orderedChannels) {
this.Plugin.ConfigInfo.Channels.TryGetValue(id, out var info);
var name = info?.Name ?? "???";
var order = "?";
if (channelOrder.TryGetValue(id, out var o)) {
order = (o + 1).ToString();
}
if (!this.Plugin.Client.ChannelRanks.TryGetValue(id, out var rank)) {
rank = Rank.Member;
}
if (ImGui.Selectable($"{order}. {rank.Symbol()}{name}###{id}", this._selectedChannel == id)) {
this._selectedChannel = id;
Task.Run(async () => await this.Plugin.Client.ListMembers(id));
}
if (first) {
first = false;
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 2);
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 3);
}
if (ImGui.BeginPopupContextItem()) {
var invited = this.Plugin.Client.InvitedChannels.ContainsKey(id);
if (invited) {
if (ImGui.Selectable("Accept invite")) {
Task.Run(async () => await this.Plugin.Client.Join(id));
}
if (ImGuiUtil.SelectableConfirm("Decline invite")) {
Task.Run(async () => await this.Plugin.Client.Leave(id));
}
} else {
if (ImGuiUtil.SelectableConfirm("Leave")) {
Task.Run(async () => await this.Plugin.Client.Leave(id));
}
if (rank == Rank.Admin) {
if (ImGuiUtil.SelectableConfirm("Disband")) {
Task.Run(async () => {
if (await this.Plugin.Client.Disband(id) is { } error) {
this.Plugin.ShowError($"Could not disband \"{name}\": {error}");
}
});
}
}
if (rank == Rank.Admin && info != null && ImGui.BeginMenu($"Rename##{id}-rename")) {
if (ImGui.IsWindowAppearing()) {
this._rename = string.Empty;
}
ImGui.SetNextItemWidth(350 * ImGuiHelpers.GlobalScale);
ImGui.InputTextWithHint($"##{id}-rename-input", "New name", ref this._rename, 64);
if (ImGui.IsWindowAppearing()) {
ImGui.SetKeyboardFocusHere(-1);
}
if (ImGui.Button($"Rename##{id}-rename-button") && !string.IsNullOrWhiteSpace(this._rename)) {
var newName = SecretBox.Encrypt(info.SharedSecret, Encoding.UTF8.GetBytes(this._rename));
Task.Run(async () => await this.Plugin.Client.UpdateToast(id, new UpdateKind.Name(newName)));
ImGui.CloseCurrentPopup();
}
ImGui.EndMenu();
}
if (ImGui.BeginMenu($"Invite##{id}-invite")) {
if (ImGui.IsWindowAppearing()) {
this._inviteName = string.Empty;
this._inviteWorld = 0;
}
ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale);
ImGui.InputTextWithHint("##invite-name", "Name", ref this._inviteName, 32);
ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale);
var preview = this._inviteWorld == 0 ? "World" : WorldUtil.WorldName(this._inviteWorld);
if (ImGui.BeginCombo("##invite-world", preview)) {
foreach (var (dc, worlds) in this._worlds) {
ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]);
ImGui.TextUnformatted(dc);
ImGui.PopStyleColor();
ImGui.Separator();
foreach (var world in worlds) {
if (ImGui.Selectable(world.Name.RawString, this._inviteWorld == world.RowId)) {
this._inviteWorld = (ushort) world.RowId;
}
}
ImGui.Spacing();
}
ImGui.EndCombo();
}
if (ImGui.Button($"Invite##{id}-invite-button") && !string.IsNullOrWhiteSpace(this._inviteName) && this._inviteWorld != 0) {
var inviteName = this._inviteName;
var inviteWorld = this._inviteWorld;
Task.Run(async () => await this.Plugin.Client.InviteToast(inviteName, inviteWorld, id));
}
ImGui.EndMenu();
}
ImGui.Separator();
if (ImGui.BeginMenu("Change number")) {
ImGui.SetNextItemWidth(150 * ImGuiHelpers.GlobalScale);
channelOrder.TryGetValue(id, out var refOrder);
var old = refOrder;
refOrder += 1;
if (ImGui.InputInt($"##{id}-order", ref refOrder)) {
refOrder = Math.Max(1, refOrder) - 1;
if (this.Plugin.ConfigInfo.ChannelOrder.TryGetValue(refOrder, out var other) && other != id) {
// another channel already has this number, so swap
this.Plugin.ConfigInfo.ChannelOrder[old] = other;
} else {
this.Plugin.ConfigInfo.ChannelOrder.Remove(old);
}
this.Plugin.ConfigInfo.ChannelOrder[refOrder] = id;
this.Plugin.SaveConfig();
this.Plugin.Commands.ReregisterAll();
}
ImGui.EndMenu();
}
if (info == null) {
if (ImGui.Selectable("Request secrets")) {
Task.Run(async () => await this.Plugin.Client.RequestSecrets(id));
}
}
}
ImGui.EndPopup();
}
}
ImGui.EndChild();
}
}
if (ImGui.TableSetColumnIndex(1) && this._selectedChannel != Guid.Empty) {
void DrawInfo() {
if (!this.Plugin.Client.TryGetChannel(this._selectedChannel, out var channel)) {
return;
}
Vector4 disabledColour;
unsafe {
disabledColour = *ImGui.GetStyleColorVec4(ImGuiCol.TextDisabled);
}
if (!this.Plugin.Client.ChannelRanks.TryGetValue(this._selectedChannel, out var rank)) {
rank = Rank.Member;
}
var first = true;
foreach (var member in channel.Members) {
if (!member.Online) {
ImGui.PushStyleColor(ImGuiCol.Text, disabledColour);
}
try {
ImGui.TextUnformatted($"{member.Rank.Symbol()}{member.Name}{CrossWorld}{WorldUtil.WorldName(member.World)}");
} finally {
if (!member.Online) {
ImGui.PopStyleColor();
}
}
if (first) {
first = false;
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 4);
anyChanged |= ImGuiUtil.Tutorial(this.Plugin, 5);
}
if (ImGui.BeginPopupContextItem($"{this._selectedChannel}-{member.Name}@{member.World}-context")) {
var cursor = ImGui.GetCursorPos();
if (rank == Rank.Admin) {
if (member.Rank is not (Rank.Admin or Rank.Invited)) {
if (ImGuiUtil.SelectableConfirm("Promote to admin", tooltip: "This will demote you to moderator.")) {
Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Admin));
}
}
if (member.Rank == Rank.Moderator && ImGuiUtil.SelectableConfirm("Demote")) {
Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Member));
}
if (member.Rank == Rank.Member && ImGuiUtil.SelectableConfirm("Promote to moderator")) {
Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Moderator));
}
}
if (rank >= Rank.Moderator) {
var canKick = member.Rank < rank && member.Rank != Rank.Invited;
if (canKick && ImGuiUtil.SelectableConfirm("Kick")) {
Task.Run(async () => {
if (await this.Plugin.Client.Kick(this._selectedChannel, member.Name, member.World) is { } error) {
this.Plugin.ShowError($"Could not kick {member.Name}: {error}");
}
});
}
if (member.Rank == Rank.Invited && ImGuiUtil.SelectableConfirm("Cancel invite")) {
Task.Run(async () => await this.Plugin.Client.Kick(this._selectedChannel, member.Name, member.World));
}
}
if (rank == Rank.Invited && member.Rank == Rank.Invited) {
if (member.Name == this.Plugin.LocalPlayer?.Name.TextValue && member.World == this.Plugin.LocalPlayer?.HomeWorld.Id) {
if (ImGui.Selectable("Accept invite")) {
Task.Run(async () => await this.Plugin.Client.Join(this._selectedChannel));
}
if (ImGuiUtil.SelectableConfirm("Decline invite")) {
Task.Run(async () => await this.Plugin.Client.Leave(this._selectedChannel));
}
}
}
if (cursor == ImGui.GetCursorPos()) {
ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]);
ImGui.TextUnformatted("No options available");
ImGui.PopStyleColor();
}
ImGui.EndPopup();
}
}
}
if (ImGui.BeginChild("channel-info", childSize)) {
DrawInfo();
ImGui.EndChild();
}
}
ImGui.EndTable();
}
AfterTable:
if (anyChanged) {
this.Plugin.SaveConfig();
}
}
}

View File

@ -0,0 +1,22 @@
using Dalamud.Game.Text.SeStringHandling.Payloads;
namespace ExtraChat.Util;
internal static class PayloadUtil {
internal static RawPayload CreateTagPayload(Guid id) {
var bytes = new List<byte> {
2, // start byte
0x27, // interactable
3 + 16, // chunk length (3 bytes plus data length)
0x20, // embedded info type (custom)
};
// now add data, we always know it's 16 bytes
bytes.AddRange(id.ToByteArray());
// end byte
bytes.Add(3);
return new RawPayload(bytes.ToArray());
}
}

View File

@ -0,0 +1,800 @@
{
"version": 1,
"dependencies": {
"net6.0-windows7.0": {
"ASodium": {
"type": "Direct",
"requested": "[0.5.3, )",
"resolved": "0.5.3",
"contentHash": "pSg92BLqGxeFTE0YPc11xZnJSB/c6DEIvzUK8hf7ofbXAh/NC6+aS8fED3W8aPDb02Tco2JjI53aDnOq6Gx83Q==",
"dependencies": {
"libsodium": "1.0.18"
}
},
"Dalamud.ContextMenu": {
"type": "Direct",
"requested": "[1.2.1, )",
"resolved": "1.2.1",
"contentHash": "RiBkn1OYRTnVbfUGYolLBE8MOeXjok+JiZaryb27oGa7YARCTu0XgUzkRiCglujknsHOn5kAaXsT3TUJmqMigg=="
},
"DalamudPackager": {
"type": "Direct",
"requested": "[2.1.8, )",
"resolved": "2.1.8",
"contentHash": "YqagNXs9InxmqkXzq7kLveImxnodkBEicAhydMXVp7dFjC7xb76U6zGgAax4/BWIWfZeWzr5DJyQSev31kj81A=="
},
"MessagePack": {
"type": "Direct",
"requested": "[2.4.35, )",
"resolved": "2.4.35",
"contentHash": "sWyuByDues5fXghrCXXt5BSiqsQSjFWC+srBU97iwWWCV3JhiI3HoML5/Uj4zK9yD8pnAk2oobuuvwYfHhUruA==",
"dependencies": {
"MessagePack.Annotations": "2.4.35",
"Microsoft.NET.StringTools": "1.0.0"
}
},
"System.Net.WebSockets.Client": {
"type": "Direct",
"requested": "[4.3.2, )",
"resolved": "4.3.2",
"contentHash": "LqSrocFY47SxEmu1fYWbUmXcFJ2B/PqnMtc6zudOTUhNINgo75hgmoHv46ynkJLmqjkpxPzl251SGWHc+jofFQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.Win32.Primitives": "4.3.0",
"System.Collections": "4.3.0",
"System.Diagnostics.Debug": "4.3.0",
"System.Diagnostics.Tracing": "4.3.0",
"System.Globalization": "4.3.0",
"System.IO": "4.3.0",
"System.Net.NameResolution": "4.3.0",
"System.Net.Primitives": "4.3.0",
"System.Net.Security": "4.3.0",
"System.Net.Sockets": "4.3.0",
"System.Net.WebHeaderCollection": "4.3.0",
"System.Net.WebSockets": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Security.Cryptography.Algorithms": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Security.Cryptography.X509Certificates": "4.3.0",
"System.Text.Encoding": "4.3.0",
"System.Text.Encoding.Extensions": "4.3.0",
"System.Threading": "4.3.0",
"System.Threading.Tasks": "4.3.0",
"System.Threading.Timer": "4.3.0"
}
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q=="
},
"libsodium": {
"type": "Transitive",
"resolved": "1.0.18",
"contentHash": "Ajv3AR9Qg/C4SQcE2ONx/UieeKnn5lSvVNc6egC3p6NP6qjZzWJ+Xg2vJURNYjkpHui/KctBwQjMPqpZK8/CHA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.0.1"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "2.4.35",
"contentHash": "6wfQ3Law5TMPeMm/HpYpMLal5HzEj70QFmbXWzHJjRsxF+nEol8RP3mqGUIctN9pItdtKCpbrpP8iEpSCkORCA=="
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "ZYVcoDM0LnSyT5nWoRGfShYdOecCw2sOXWwP6j1Z0u48Xq3+BVvZ+EiPCX9/8Gz439giW+O1H1kWF9Eb/w6rVg==",
"dependencies": {
"System.Memory": "4.5.4",
"System.Runtime.CompilerServices.Unsafe": "5.0.0"
}
},
"Microsoft.NETCore.Platforms": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
},
"Microsoft.NETCore.Targets": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg=="
},
"Microsoft.Win32.Primitives": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q=="
},
"runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA=="
},
"runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw=="
},
"runtime.native.System": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0"
}
},
"runtime.native.System.Net.Http": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0"
}
},
"runtime.native.System.Net.Security": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "M2nN92ePS8BgQ2oi6Jj3PlTUzadYSIWLdZrHY1n1ZcW9o4wAQQ6W+aQ2lfq1ysZQfVCgDwY58alUdowrzezztg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0"
}
},
"runtime.native.System.Security.Cryptography.Apple": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==",
"dependencies": {
"runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0"
}
},
"runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==",
"dependencies": {
"runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0",
"runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A=="
},
"runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ=="
},
"runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ=="
},
"runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g=="
},
"runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg=="
},
"runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ=="
},
"runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A=="
},
"runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg=="
},
"System.Collections": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Collections.Concurrent": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==",
"dependencies": {
"System.Collections": "4.3.0",
"System.Diagnostics.Debug": "4.3.0",
"System.Diagnostics.Tracing": "4.3.0",
"System.Globalization": "4.3.0",
"System.Reflection": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Threading": "4.3.0",
"System.Threading.Tasks": "4.3.0"
}
},
"System.Diagnostics.Debug": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Diagnostics.Tracing": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Globalization": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Globalization.Calendars": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Globalization": "4.3.0",
"System.Runtime": "4.3.0"
}
},
"System.Globalization.Extensions": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"System.Globalization": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.InteropServices": "4.3.0"
}
},
"System.IO": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0",
"System.Text.Encoding": "4.3.0",
"System.Threading.Tasks": "4.3.0"
}
},
"System.IO.FileSystem": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.IO": "4.3.0",
"System.IO.FileSystem.Primitives": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Text.Encoding": "4.3.0",
"System.Threading.Tasks": "4.3.0"
}
},
"System.IO.FileSystem.Primitives": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==",
"dependencies": {
"System.Runtime": "4.3.0"
}
},
"System.Linq": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==",
"dependencies": {
"System.Collections": "4.3.0",
"System.Diagnostics.Debug": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw=="
},
"System.Net.NameResolution": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "AFYl08R7MrsrEjqpQWTZWBadqXyTzNDaWpMqyxhb0d6sGhV6xMDKueuBXlLL30gz+DIRY6MpdgnHWlCh5wmq9w==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"System.Collections": "4.3.0",
"System.Diagnostics.Tracing": "4.3.0",
"System.Globalization": "4.3.0",
"System.Net.Primitives": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Security.Principal.Windows": "4.3.0",
"System.Threading": "4.3.0",
"System.Threading.Tasks": "4.3.0",
"runtime.native.System": "4.3.0"
}
},
"System.Net.Primitives": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0",
"System.Runtime.Handles": "4.3.0"
}
},
"System.Net.Security": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "IgJKNfALqw7JRWp5LMQ5SWHNKvXVz094U6wNE3c1i8bOkMQvgBL+MMQuNt3xl9Qg9iWpj3lFxPZEY6XHmROjMQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.Win32.Primitives": "4.3.0",
"System.Collections": "4.3.0",
"System.Collections.Concurrent": "4.3.0",
"System.Diagnostics.Tracing": "4.3.0",
"System.Globalization": "4.3.0",
"System.Globalization.Extensions": "4.3.0",
"System.IO": "4.3.0",
"System.Net.Primitives": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Security.Claims": "4.3.0",
"System.Security.Cryptography.Algorithms": "4.3.0",
"System.Security.Cryptography.Encoding": "4.3.0",
"System.Security.Cryptography.OpenSsl": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Security.Cryptography.X509Certificates": "4.3.0",
"System.Security.Principal": "4.3.0",
"System.Text.Encoding": "4.3.0",
"System.Threading": "4.3.0",
"System.Threading.Tasks": "4.3.0",
"System.Threading.ThreadPool": "4.3.0",
"runtime.native.System": "4.3.0",
"runtime.native.System.Net.Security": "4.3.0",
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"System.Net.Sockets": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.IO": "4.3.0",
"System.Net.Primitives": "4.3.0",
"System.Runtime": "4.3.0",
"System.Threading.Tasks": "4.3.0"
}
},
"System.Net.WebHeaderCollection": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "XZrXYG3c7QV/GpWeoaRC02rM6LH2JJetfVYskf35wdC/w2fFDFMphec4gmVH2dkll6abtW14u9Rt96pxd9YH2A==",
"dependencies": {
"System.Collections": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0"
}
},
"System.Net.WebSockets": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "u6fFNY5q4T8KerUAVbya7bR6b7muBuSTAersyrihkcmE5QhEOiH3t5rh4il15SexbVlpXFHGuMwr/m8fDrnkQg==",
"dependencies": {
"Microsoft.Win32.Primitives": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Threading.Tasks": "4.3.0"
}
},
"System.Reflection": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.IO": "4.3.0",
"System.Reflection.Primitives": "4.3.0",
"System.Runtime": "4.3.0"
}
},
"System.Reflection.Primitives": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Resources.ResourceManager": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Globalization": "4.3.0",
"System.Reflection": "4.3.0",
"System.Runtime": "4.3.0"
}
},
"System.Runtime": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0"
}
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "ZD9TMpsmYJLrxbbmdvhwt9YEgG5WntEnZ/d1eH8JBX9LBp+Ju8BSBhUGbZMNVHHomWo2KVImJhTDl2hIgw/6MA=="
},
"System.Runtime.Extensions": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Runtime.Handles": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Runtime.InteropServices": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Reflection": "4.3.0",
"System.Reflection.Primitives": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Handles": "4.3.0"
}
},
"System.Runtime.Numerics": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==",
"dependencies": {
"System.Globalization": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0"
}
},
"System.Security.Claims": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "P/+BR/2lnc4PNDHt/TPBAWHVMLMRHsyYZbU1NphW4HIWzCggz8mJbTQQ3MKljFE7LS3WagmVFuBgoLcFzYXlkA==",
"dependencies": {
"System.Collections": "4.3.0",
"System.Globalization": "4.3.0",
"System.IO": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Security.Principal": "4.3.0"
}
},
"System.Security.Cryptography.Algorithms": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"System.Collections": "4.3.0",
"System.IO": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Runtime.Numerics": "4.3.0",
"System.Security.Cryptography.Encoding": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Text.Encoding": "4.3.0",
"runtime.native.System.Security.Cryptography.Apple": "4.3.0",
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"System.Security.Cryptography.Cng": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"System.IO": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Security.Cryptography.Algorithms": "4.3.0",
"System.Security.Cryptography.Encoding": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Text.Encoding": "4.3.0"
}
},
"System.Security.Cryptography.Csp": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"System.IO": "4.3.0",
"System.Reflection": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Security.Cryptography.Algorithms": "4.3.0",
"System.Security.Cryptography.Encoding": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Text.Encoding": "4.3.0",
"System.Threading": "4.3.0"
}
},
"System.Security.Cryptography.Encoding": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"System.Collections": "4.3.0",
"System.Collections.Concurrent": "4.3.0",
"System.Linq": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Text.Encoding": "4.3.0",
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"System.Security.Cryptography.OpenSsl": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==",
"dependencies": {
"System.Collections": "4.3.0",
"System.IO": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Runtime.Numerics": "4.3.0",
"System.Security.Cryptography.Algorithms": "4.3.0",
"System.Security.Cryptography.Encoding": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Text.Encoding": "4.3.0",
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"System.Security.Cryptography.Primitives": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==",
"dependencies": {
"System.Diagnostics.Debug": "4.3.0",
"System.Globalization": "4.3.0",
"System.IO": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Threading": "4.3.0",
"System.Threading.Tasks": "4.3.0"
}
},
"System.Security.Cryptography.X509Certificates": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"System.Collections": "4.3.0",
"System.Diagnostics.Debug": "4.3.0",
"System.Globalization": "4.3.0",
"System.Globalization.Calendars": "4.3.0",
"System.IO": "4.3.0",
"System.IO.FileSystem": "4.3.0",
"System.IO.FileSystem.Primitives": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Runtime.Numerics": "4.3.0",
"System.Security.Cryptography.Algorithms": "4.3.0",
"System.Security.Cryptography.Cng": "4.3.0",
"System.Security.Cryptography.Csp": "4.3.0",
"System.Security.Cryptography.Encoding": "4.3.0",
"System.Security.Cryptography.OpenSsl": "4.3.0",
"System.Security.Cryptography.Primitives": "4.3.0",
"System.Text.Encoding": "4.3.0",
"System.Threading": "4.3.0",
"runtime.native.System": "4.3.0",
"runtime.native.System.Net.Http": "4.3.0",
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"System.Security.Principal": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "I1tkfQlAoMM2URscUtpcRo/hX0jinXx6a/KUtEQoz3owaYwl3qwsO8cbzYVVnjxrzxjHo3nJC+62uolgeGIS9A==",
"dependencies": {
"System.Runtime": "4.3.0"
}
},
"System.Security.Principal.Windows": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "HVL1rvqYtnRCxFsYag/2le/ZfKLK4yMw79+s6FmKXbSCNN0JeAhrYxnRAHFoWRa0dEojsDcbBSpH3l22QxAVyw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.Win32.Primitives": "4.3.0",
"System.Collections": "4.3.0",
"System.Diagnostics.Debug": "4.3.0",
"System.Reflection": "4.3.0",
"System.Resources.ResourceManager": "4.3.0",
"System.Runtime": "4.3.0",
"System.Runtime.Extensions": "4.3.0",
"System.Runtime.Handles": "4.3.0",
"System.Runtime.InteropServices": "4.3.0",
"System.Security.Claims": "4.3.0",
"System.Security.Principal": "4.3.0",
"System.Text.Encoding": "4.3.0",
"System.Threading": "4.3.0"
}
},
"System.Text.Encoding": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Text.Encoding.Extensions": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0",
"System.Text.Encoding": "4.3.0"
}
},
"System.Threading": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==",
"dependencies": {
"System.Runtime": "4.3.0",
"System.Threading.Tasks": "4.3.0"
}
},
"System.Threading.Tasks": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"System.Threading.ThreadPool": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "k/+g4b7vjdd4aix83sTgC9VG6oXYKAktSfNIJUNGxPEj7ryEOfzHHhfnmsZvjxawwcD9HyWXKCXmPjX8U4zeSw==",
"dependencies": {
"System.Runtime": "4.3.0",
"System.Runtime.Handles": "4.3.0"
}
},
"System.Threading.Timer": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
}
}
}
}

2
server/Cargo.lock generated
View File

@ -336,6 +336,7 @@ dependencies = [
"prefixed-api-key",
"rand 0.8.5",
"regex",
"reqwest",
"rmp-serde",
"rustyline",
"serde",
@ -346,6 +347,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"toml",
"url",
"uuid",
]

View File

@ -18,16 +18,17 @@ parking_lot = "0.12"
prefixed-api-key = { git = "https://git.annaclemens.io/ascclemens/prefixed-api-key.git" }
rand = "0.8"
regex = "1"
reqwest = { version = "0.11", default-features = false }
rmp-serde = "1"
rustyline = { version = "9", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_repr = "0.1"
sha3 = "0.10"
#sodiumoxide = "0.2"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "sqlite", "chrono"] }
tokio-tungstenite = "0.17"
toml = "0.5"
url = { version = "2", features = ["serde"] }
uuid = { version = "1", features = ["serde", "v4"] }
[dependencies.tokio]

View File

@ -0,0 +1,13 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::{ClientState, State, util, WsStream};
use crate::types::protocol::{AllowInvitesRequest, AllowInvitesResponse};
pub async fn allow_invites(_state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: AllowInvitesRequest) -> anyhow::Result<()> {
client_state.write().await.allow_invites = req.allowed;
util::send(conn, number, AllowInvitesResponse {
allowed: req.allowed,
}).await
}

View File

@ -3,7 +3,6 @@ use std::sync::Arc;
use anyhow::Context;
use chrono::{Duration, Utc};
use lodestone_scraper::LodestoneScraper;
use log::trace;
use tokio::sync::RwLock;
@ -11,8 +10,7 @@ use crate::{AuthenticateRequest, AuthenticateResponse, ClientState, State, User,
pub async fn authenticate(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: AuthenticateRequest) -> anyhow::Result<()> {
if client_state.read().await.user.is_some() {
util::send(conn, number, AuthenticateResponse::error("already logged in")).await?;
return Ok(());
return util::send(conn, number, AuthenticateResponse::error("already logged in")).await;
}
let key = prefixed_api_key::parse(&*req.key)
@ -27,38 +25,20 @@ pub async fn authenticate(state: Arc<RwLock<State>>, client_state: Arc<RwLock<Cl
.fetch_optional(&state.read().await.db)
.await
.context("could not query database for user")?;
let mut user = match user {
let user = match user {
Some(u) => u,
None => {
util::send(conn, number, AuthenticateResponse::error("invalid key")).await?;
return Ok(());
}
None => return util::send(conn, number, AuthenticateResponse::error("invalid key")).await,
};
if Utc::now().naive_utc().signed_duration_since(user.last_updated) >= Duration::hours(2) {
let info = LodestoneScraper::default()
.character(user.lodestone_id as u64)
.await
.context("could not get character info")?;
let world_name = info.world.as_str();
user.name = info.name.clone();
user.world = world_name.to_string();
sqlx::query!(
// language=sqlite
"update users set name = ?, world = ?, last_updated = current_timestamp where lodestone_id = ?",
info.name,
world_name,
user.lodestone_id,
)
.execute(&state.read().await.db)
.await
.context("could not update user")?;
}
let world = World::from_str(&user.world).map_err(|_| anyhow::anyhow!("invalid world in db"))?;
if let Some(old_client_state) = state.read().await.clients.get(&(user.lodestone_id as u64)) {
let mut lock = old_client_state.write().await;
// this prevents the old client thread from removing info from the global state
lock.user = None;
lock.shutdown_tx.send(()).await.ok();
}
trace!(" [authenticate] before user write");
let mut c_state = client_state.write().await;
c_state.user = Some(User {
@ -69,6 +49,7 @@ pub async fn authenticate(state: Arc<RwLock<State>>, client_state: Arc<RwLock<Cl
});
c_state.pk = req.pk.into_inner();
c_state.allow_invites = req.allow_invites;
// release lock asap
drop(c_state);
@ -80,7 +61,9 @@ pub async fn authenticate(state: Arc<RwLock<State>>, client_state: Arc<RwLock<Cl
state.write().await.ids.insert((user.name, util::id_from_world(world)), user.lodestone_id as u64);
trace!(" [authenticate] after state writes");
util::send(conn, number, AuthenticateResponse::success()).await?;
if Utc::now().naive_utc().signed_duration_since(user.last_updated) >= Duration::hours(2) {
state.read().await.updater_tx.send(user.lodestone_id).ok();
}
Ok(())
util::send(conn, number, AuthenticateResponse::success()).await
}

View File

@ -0,0 +1,39 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, State, WsStream};
use crate::types::protocol::{DeleteAccountRequest, DeleteAccountResponse};
pub async fn delete_account(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, _req: DeleteAccountRequest) -> Result<()> {
let id = match client_state.read().await.lodestone_id() {
Some(id) => id,
None => return crate::util::send(conn, number, ErrorResponse::new(None, "no Lodestone ID? this is a bug")).await,
};
let lodestone_id = id as i64;
let channels = sqlx::query!(
// language=sqlite
"select count(*) as count from user_channels where lodestone_id = ?",
lodestone_id,
)
.fetch_one(&state.read().await.db)
.await
.context("could not get channel count")?;
if channels.count > 0 {
return crate::util::send(conn, number, ErrorResponse::new(None, "leave all linkshells first")).await;
}
sqlx::query!(
// language=sqlite
"delete from users where lodestone_id = ?",
lodestone_id,
)
.execute(&state.read().await.db)
.await
.context("could not delete user")?;
crate::util::send(conn, number, DeleteAccountResponse {}).await
}

View File

@ -23,12 +23,19 @@ pub async fn invite(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientSt
return crate::util::send(conn, number, ErrorResponse::new(req.channel, "not enough permissions to invite")).await;
}
const NOT_ONLINE: &str = "user not online";
let target_id = match state.read().await.ids.get(&(req.name.clone(), req.world)) {
Some(id) => *id,
None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, "user not online")).await,
None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, NOT_ONLINE)).await,
};
let target_id_i = target_id as i64;
if let Some(client) = state.read().await.clients.get(&target_id) {
if !client.read().await.allow_invites {
return crate::util::send(conn, number, ErrorResponse::new(req.channel, NOT_ONLINE)).await;
}
}
if target_id_i == lodestone_id {
return crate::util::send(conn, number, ErrorResponse::new(req.channel, "cannot invite self")).await;
}
@ -110,7 +117,7 @@ pub async fn invite(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientSt
}),
}).await?;
}
None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, "user not online")).await,
None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, NOT_ONLINE)).await,
}
crate::util::send(conn, number, InviteResponse {

View File

@ -1,6 +1,8 @@
pub use self::{
allow_invites::*,
authenticate::*,
create::*,
delete_account::*,
disband::*,
invite::*,
join::*,
@ -18,8 +20,10 @@ pub use self::{
version::*,
};
pub mod allow_invites;
pub mod authenticate;
pub mod create;
pub mod delete_account;
pub mod disband;
pub mod invite;
pub mod join;

View File

@ -10,25 +10,20 @@ use crate::util::redacted::Redacted;
pub async fn public_key(state: Arc<RwLock<State>>, conn: &mut WsStream, number: u32, req: PublicKeyRequest) -> Result<()> {
let id = match state.read().await.ids.get(&(req.name.clone(), req.world)) {
Some(id) => *id,
None => {
crate::util::send(conn, number, PublicKeyResponse {
name: req.name,
world: req.world,
pk: None,
}).await?;
return Ok(());
}
None => return crate::util::send(conn, number, PublicKeyResponse {
name: req.name,
world: req.world,
pk: None,
}).await,
};
let pk = match state.read().await.clients.get(&id) {
Some(client) => Some(client.read().await.pk.clone()),
None => None,
Some(client) if client.read().await.allow_invites => Some(client.read().await.pk.clone()),
_ => None,
};
crate::util::send(conn, number, PublicKeyResponse {
name: req.name,
world: req.world,
pk: pk.map(Redacted),
}).await?;
Ok(())
pk: pk.map(Redacted::new),
}).await
}

136
server/src/influx.rs Normal file
View File

@ -0,0 +1,136 @@
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use chrono::Utc;
use log::{debug, error};
use reqwest::Client;
use tokio::sync::RwLock;
use crate::{Config, State};
pub fn spawn(config: &Config, state: Arc<RwLock<State>>) {
let influx = match &config.influx {
Some(i) => i,
None => return,
};
let mut url = match influx.url.join("/api/v2/write") {
Ok(url) => url,
Err(e) => {
error!("Failed to parse influxdb url: {}", e);
return;
}
};
url.query_pairs_mut()
.append_pair("org", &influx.org)
.append_pair("bucket", &influx.bucket);
let influx_token = influx.token.clone();
tokio::task::spawn(async move {
let mut last_messages = 0;
let client = Client::new();
loop {
let messages = state.read().await.messages_sent.load(Ordering::SeqCst);
let diff = messages - last_messages;
last_messages = messages;
let clients = state.read().await.clients.len();
let num_users = sqlx::query!(
// language=sqlite
"select count(*) as count from users"
)
.fetch_one(&state.read().await.db)
.await
.ok();
let in_one = sqlx::query!(
// language=sqlite
"select count(distinct lodestone_id) as count from user_channels"
)
.fetch_one(&state.read().await.db)
.await
.ok();
let num_linkshells = sqlx::query!(
// language=sqlite
"select count(*) as count from channels"
)
.fetch_one(&state.read().await.db)
.await
.ok();
let outstanding_invites = sqlx::query!(
// language=sqlite
"select count(*) as count from channel_invites"
)
.fetch_one(&state.read().await.db)
.await
.ok();
let timestamp = Utc::now().timestamp_nanos();
let mut line_format = format!(
"logged_in value={logged_in}u {timestamp}\nmessages_this_instance value={messages_this_instance}u {timestamp}\nmessages_new value={messages_new}u {timestamp}\n",
logged_in = clients,
messages_this_instance = messages,
messages_new = diff,
timestamp = timestamp,
);
if let Some(num_users) = num_users {
line_format.push_str(&format!(
"users value={users}u {timestamp}\n",
users = num_users.count,
timestamp = timestamp,
));
}
if let Some(in_one) = in_one {
line_format.push_str(&format!(
"users_in_at_least_one_linkshell value={in_one}u {timestamp}\n",
in_one = in_one.count,
timestamp = timestamp,
));
}
if let Some(num_linkshells) = num_linkshells {
line_format.push_str(&format!(
"linkshells value={linkshells}u {timestamp}\n",
linkshells = num_linkshells.count,
timestamp = timestamp,
));
}
if let Some(outstanding_invites) = outstanding_invites {
line_format.push_str(&format!(
"outstanding_invites value={outstanding_invites}u {timestamp}\n",
outstanding_invites = outstanding_invites.count,
timestamp = timestamp,
));
}
debug!("line_format: {}", line_format);
let res = client.post(url.clone())
.header("Authorization", format!("Token {}", influx_token))
.body(line_format)
.send()
.await
.and_then(|resp| resp.error_for_status());
if let Err(e) = res {
error!("failed to send to influxdb: {}", e);
} else {
debug!("sent to influxdb");
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
});
}

View File

@ -17,7 +17,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::{
net::{TcpListener, TcpStream},
};
use tokio::sync::mpsc::Sender;
use tokio::sync::mpsc::{Sender, UnboundedSender};
use tokio::sync::RwLock;
use tokio_tungstenite::{
tungstenite::Message as WsMessage,
@ -49,6 +49,7 @@ pub mod handlers;
pub mod util;
pub mod updater;
pub mod logging;
pub mod influx;
pub type WsStream = WebSocketStream<TcpStream>;
@ -58,6 +59,7 @@ pub struct State {
pub ids: HashMap<(String, u16), u64>,
pub secrets_requests: HashMap<Uuid, SecretsRequestInfo>,
pub messages_sent: AtomicU64,
pub updater_tx: UnboundedSender<i64>,
}
impl State {
@ -125,6 +127,9 @@ async fn main() -> Result<()> {
.await
.context("could not run database migrations")?;
// set up updater channel
let (updater_tx, updater_rx) = tokio::sync::mpsc::unbounded_channel();
// set up server
let server = TcpListener::bind(&config.server.address).await?;
let state = Arc::new(RwLock::new(State {
@ -133,6 +138,7 @@ async fn main() -> Result<()> {
ids: Default::default(),
secrets_requests: Default::default(),
messages_sent: AtomicU64::default(),
updater_tx,
}));
info!("Listening on ws://{}/", server.local_addr()?);
@ -188,18 +194,29 @@ async fn main() -> Result<()> {
{
let state = Arc::clone(&state);
tokio::task::spawn(async move {
let mut last_messages = 0;
loop {
let messages = state.read().await.messages_sent.load(Ordering::SeqCst);
let diff = messages - last_messages;
last_messages = messages;
let clients = state.read().await.clients.len();
info!(
"Clients: {}, messages sent: {}",
state.read().await.clients.len(),
state.read().await.messages_sent.load(Ordering::SeqCst),
"Clients: {}, messages sent: {} (+{})",
clients,
messages,
diff,
);
tokio::time::sleep(Duration::from_secs(60)).await;
}
});
}
updater::spawn(Arc::clone(&state));
influx::spawn(&config, Arc::clone(&state));
updater::spawn(Arc::clone(&state), updater_rx);
loop {
let res: Result<()> = try {
@ -244,7 +261,9 @@ async fn main() -> Result<()> {
pub struct ClientState {
user: Option<User>,
tx: Sender<ResponseContainer>,
shutdown_tx: Sender<()>,
pk: Vec<u8>,
allow_invites: bool,
}
impl ClientState {
@ -327,16 +346,23 @@ impl ClientState {
async fn client_loop(state: Arc<RwLock<State>>, mut conn: WsStream) -> Result<()> {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel(1);
let client_state = Arc::new(RwLock::new(ClientState {
user: None,
tx,
shutdown_tx,
pk: Default::default(),
allow_invites: false,
}));
loop {
let res: Result<()> = try {
tokio::select! {
_ = shutdown_rx.recv() => {
debug!("break due to new login");
break;
}
msg = rx.recv() => {
if let Some(msg) = msg {
let encoded = rmp_serde::to_vec(&msg)?;
@ -410,6 +436,12 @@ async fn client_loop(state: Arc<RwLock<State>>, mut conn: WsStream) -> Result<()
RequestKind::SendSecrets(req) if logged_in => {
crate::handlers::send_secrets(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?;
}
RequestKind::AllowInvites(req) if logged_in => {
crate::handlers::allow_invites(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?;
}
RequestKind::DeleteAccount(req) if logged_in => {
crate::handlers::delete_account(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?;
}
_ if !logged_in => {
util::send(&mut conn, msg.number, ErrorResponse::new(None, "not logged in")).await?;
}
@ -434,10 +466,14 @@ async fn client_loop(state: Arc<RwLock<State>>, mut conn: WsStream) -> Result<()
}
}
debug!("ending client thread");
if let Some(user) = &client_state.read().await.user {
state.write().await.clients.remove(&user.lodestone_id);
state.write().await.ids.remove(&(user.name.clone(), util::id_from_world(user.world)));
}
debug!("client thread ended");
Ok(())
}

View File

@ -1,9 +1,12 @@
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
pub server: Server,
pub database: Database,
#[serde(default)]
pub influx: Option<Influx>,
}
#[derive(Debug, Deserialize, Serialize)]
@ -15,3 +18,11 @@ pub struct Server {
pub struct Database {
pub path: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Influx {
pub url: Url,
pub org: String,
pub bucket: String,
pub token: String,
}

View File

@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllowInvitesRequest {
pub allowed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllowInvitesResponse {
pub allowed: bool,
}

View File

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::util::redacted::Redacted;
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -6,6 +7,8 @@ pub struct AuthenticateRequest {
pub key: Redacted<String>,
#[serde(with = "serde_bytes")]
pub pk: Redacted<Vec<u8>>,
#[serde(default = "default_true")]
pub allow_invites: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -26,3 +29,7 @@ impl AuthenticateResponse {
}
}
}
fn default_true() -> bool {
true
}

View File

@ -28,6 +28,8 @@ pub enum RequestKind {
PublicKey(PublicKeyRequest),
Secrets(SecretsRequest),
SendSecrets(SendSecretsRequest),
AllowInvites(AllowInvitesRequest),
DeleteAccount(DeleteAccountRequest),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -61,6 +63,8 @@ pub enum ResponseKind {
Secrets(SecretsResponse),
SendSecrets(SendSecretsResponse),
Announce(AnnounceResponse),
AllowInvites(AllowInvitesResponse),
DeleteAccount(DeleteAccountResponse),
}
macro_rules! request_container {
@ -90,6 +94,8 @@ request_container!(Update, UpdateRequest);
request_container!(PublicKey, PublicKeyRequest);
request_container!(Secrets, SecretsRequest);
request_container!(SendSecrets, SendSecretsRequest);
request_container!(AllowInvites, AllowInvitesRequest);
request_container!(DeleteAccount, DeleteAccountRequest);
macro_rules! response_container {
($name:ident, $response:ty) => {
@ -123,3 +129,5 @@ response_container!(MemberChange, MemberChangeResponse);
response_container!(Secrets, SecretsResponse);
response_container!(SendSecrets, SendSecretsResponse);
response_container!(Announce, AnnounceResponse);
response_container!(AllowInvites, AllowInvitesResponse);
response_container!(DeleteAccount, DeleteAccountResponse);

View File

@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteAccountRequest {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteAccountResponse {
}

View File

@ -1,7 +1,34 @@
pub use self::{
allow_invites::*,
announce::*,
authenticate::*,
container::*,
create::*,
delete_account::*,
disband::*,
error::*,
invite::*,
join::*,
kick::*,
leave::*,
list::*,
member_change::*,
message::*,
ping::*,
promote::*,
public_key::*,
register::*,
secrets::*,
update::*,
version::*,
};
pub mod allow_invites;
pub mod announce;
pub mod authenticate;
pub mod container;
pub mod create;
pub mod delete_account;
pub mod disband;
pub mod error;
pub mod invite;
@ -21,25 +48,3 @@ pub mod version;
pub mod channel;
pub use self::{
announce::*,
authenticate::*,
container::*,
create::*,
disband::*,
error::*,
invite::*,
join::*,
kick::*,
leave::*,
list::*,
member_change::*,
message::*,
ping::*,
promote::*,
public_key::*,
register::*,
secrets::*,
update::*,
version::*,
};

View File

@ -1,57 +1,47 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::{
sync::Arc,
time::Duration,
};
use anyhow::{Context, Result};
use lodestone_scraper::LodestoneScraper;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use log::{error, info, trace};
use log::{debug, error, trace};
use tokio::{
sync::{
mpsc::UnboundedReceiver,
RwLock,
},
task::JoinHandle,
time::Instant,
};
use crate::State;
pub fn spawn(state: Arc<RwLock<State>>) -> JoinHandle<()> {
pub fn spawn(state: Arc<RwLock<State>>, mut rx: UnboundedReceiver<i64>) -> JoinHandle<()> {
const WAIT_TIME: u64 = 5;
tokio::task::spawn(async move {
let lodestone = LodestoneScraper::default();
loop {
match inner(&state, &lodestone).await {
Ok(results) => {
let successful = results.values().filter(|result| result.is_ok()).count();
info!("Updated {}/{} characters", successful, results.len());
for (id, result) in results {
if let Err(e) = result {
error!("error updating user {}: {:?}", id, e);
}
}
}
Err(e) => {
error!("error updating users: {:?}", e);
}
let mut last_update = Instant::now();
while let Some(id) = rx.recv().await {
// make sure to wait five seconds between each request
let elapsed = last_update.elapsed();
if elapsed < Duration::from_secs(WAIT_TIME) {
let left = Duration::from_secs(WAIT_TIME) - elapsed;
tokio::time::sleep(left).await;
}
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
match update(&*state, &lodestone, id).await {
Ok(()) => debug!("updated user {}", id),
Err(e) => error!("error updating user {}: {:?}", id, e),
}
last_update = Instant::now();
}
})
}
async fn inner(state: &RwLock<State>, lodestone: &LodestoneScraper) -> Result<HashMap<u32, Result<()>>> {
let users = sqlx::query!(
// language=sqlite
"select * from users where (julianday(current_timestamp) - julianday(last_updated)) * 24 >= 2",
)
.fetch_all(&state.read().await.db)
.await
.context("could not query database for users")?;
let mut results = HashMap::with_capacity(users.len());
for user in users {
results.insert(user.lodestone_id as u32, update(state, lodestone, user.lodestone_id).await);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
Ok(results)
}
async fn update(state: &RwLock<State>, lodestone: &LodestoneScraper, lodestone_id: i64) -> Result<()> {
let info = lodestone
.character(lodestone_id as u64)
@ -83,4 +73,4 @@ async fn update(state: &RwLock<State>, lodestone: &LodestoneScraper, lodestone_i
}
Ok(())
}
}

View File

@ -7,9 +7,13 @@ use sqlx::database::HasArguments;
use sqlx::encode::IsNull;
#[repr(transparent)]
pub struct Redacted<T>(pub T);
pub struct Redacted<T>(T);
impl<T> Redacted<T> {
pub fn new(t: T) -> Self {
Self(t)
}
pub fn into_inner(self) -> T {
self.0
}