chore: initial commit

This commit is contained in:
Anna 2022-07-07 22:58:32 -04:00
commit c917141324
Signed by: anna
GPG Key ID: 0B391D8F06FCD9E0
129 changed files with 10126 additions and 0 deletions

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# ExtraChat
ExtraChat is a Dalamud plugin and an associated server that function
to add extra chat channels to FFXIV. Basically, this adds
cross-data-centre linkshells that don't have a member limit.
## Security and privacy
For your privacy and to ensure a lack of liability for server hosts,
messages and linkshell names are encrypted between the members of each
linkshell. The server is unable to decrypt the content of messages or
linkshell names.
The server *does* know which characters are in which linkshells (an
operational requirement).
Due to this design decision, it is impossible for a server to moderate
these extra linkshells, and linkshells will need to self-moderate
instead. As such, there is no ability to report users.
## Encryption details
When a user initiates the process to create a linkshell, their client
generates a random shared secret. The secret is saved locally by the
client. When the user invites another user to the linkshell, a
Diffie-Hellman key exchange is mediated by the server between the two
users, and then the inviter transmits the shared secret to the
invitee, encrypting it with their ephemeral shared secret created by
the key exchange. Due to the nature of the Diffie-Hellman exchange,
the server is unable to read the shared secret when it is sent.
After this, the newly-invited user receives information about the
linkshell they have been invited to, and can decrypt the name, as well
as see any members. If the invitee decides to join, their client will
save this shared secret.
Any messages sent to the linkshell are encrypted with the shared
secret, making their contents opaque to the server. The only way to
read these messages is to know the shared secret, which the server is
never able to discern.

365
client/.gitignore vendored Normal file
View File

@ -0,0 +1,365 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Packaging
pack/
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

16
client/ExtraChat.sln Normal file
View File

@ -0,0 +1,16 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtraChat", "ExtraChat\ExtraChat.csproj", "{19904E25-FE96-41F8-BED5-24894CF189C2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{19904E25-FE96-41F8-BED5-24894CF189C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{19904E25-FE96-41F8-BED5-24894CF189C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{19904E25-FE96-41F8-BED5-24894CF189C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{19904E25-FE96-41F8-BED5-24894CF189C2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,18 @@
namespace ExtraChat;
internal static class ArrayExtensions {
internal static byte[] Concat(this byte[] a, byte[] b) {
var result = new byte[a.Length + b.Length];
var idx = 0;
foreach (var t in a) {
result[idx++] = t;
}
foreach (var t in b) {
result[idx++] = t;
}
return result;
}
}

950
client/ExtraChat/Client.cs Normal file
View File

@ -0,0 +1,950 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.WebSockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Channels;
using ASodium;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Logging;
using Dalamud.Utility;
using ExtraChat.Protocol;
using ExtraChat.Protocol.Channels;
using ExtraChat.Ui;
using ExtraChat.Util;
using Lumina.Excel.GeneratedSheets;
using Channel = ExtraChat.Protocol.Channels.Channel;
namespace ExtraChat;
internal class Client : IDisposable {
private const int IsUpPingNumber = 42069;
internal enum State {
Disconnected,
Connecting,
NotAuthenticated,
RetrievingChallenge,
WaitingForVerification,
Verifying,
Authenticating,
FailedAuthentication,
Connected,
}
private Plugin Plugin { get; }
private ClientWebSocket WebSocket { get; set; }
internal State Status { get; private set; } = State.Disconnected;
private bool _active = true;
private uint _number = 1;
private bool _wasConnected;
private KeyPair KeyPair { get; }
private readonly Mutex _waitersMutex = new();
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>>?)>();
internal Dictionary<Guid, Channel> Channels { get; } = new();
internal Dictionary<Guid, Channel> InvitedChannels { get; } = new();
internal Dictionary<Guid, Rank> ChannelRanks { get; } = new();
internal Client(Plugin plugin) {
this.Plugin = plugin;
this.WebSocket = new ClientWebSocket();
this.KeyPair = SodiumKeyExchange.GenerateKeyPair();
this.Plugin.ClientState.Login += this.Login;
this.Plugin.ClientState.Logout += this.Logout;
if (this.Plugin.ClientState.IsLoggedIn) {
this.StartLoop();
}
}
public void Dispose() {
this.Plugin.ClientState.Login -= this.Login;
this.Plugin.ClientState.Logout -= this.Logout;
this._active = false;
this.WebSocket.Dispose();
}
private void Login(object? sender, EventArgs e) {
this.StartLoop();
}
private void Logout(object? sender, EventArgs e) {
this.StopLoop();
}
internal bool TryGetChannel(Guid id, [MaybeNullWhen(false)] out Channel channel) {
return this.Channels.TryGetValue(id, out channel) || this.InvitedChannels.TryGetValue(id, out channel);
}
internal void StopLoop() {
this._active = false;
this.WebSocket.Abort();
}
internal void StartLoop() {
this._active = true;
Task.Run(async () => {
while (this._active) {
try {
await this.Loop();
} catch (Exception ex) {
PluginLog.LogError(ex, "Error in client loop");
if (this._wasConnected) {
this.Plugin.ChatGui.PrintChat(new XivChatEntry {
Message = "Disconnected from ExtraChat. Trying to reconnect.",
Type = XivChatType.Urgent,
});
}
}
await Task.Delay(TimeSpan.FromSeconds(3));
}
// ReSharper disable once FunctionNeverReturns
});
}
private 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();
return channel.Reader;
}
private async Task QueueMessage(RequestKind request) {
var container = new RequestContainer {
Number = this._number++,
Kind = request,
};
await this.ToSend.Writer.WriteAsync((container, null));
}
private async Task<ResponseKind> QueueMessageAndWait(RequestKind request) {
var container = new RequestContainer {
Number = this._number++,
Kind = request,
};
var channel = System.Threading.Channels.Channel.CreateBounded<ChannelReader<ResponseKind>>(1);
await this.ToSend.Writer.WriteAsync((container, channel.Writer));
var what = await channel.Reader.ReadAsync();
return await what.ReadAsync();
}
private byte[] GetPrivateKey() {
var key = new byte[this.KeyPair.GetPrivateKeyLength()];
SodiumGuardedHeapAllocation.Sodium_MProtect_ReadOnly(this.KeyPair.GetPrivateKey());
Marshal.Copy(this.KeyPair.GetPrivateKey(), key, 0, this.KeyPair.GetPrivateKeyLength());
SodiumGuardedHeapAllocation.Sodium_MProtect_NoAccess(this.KeyPair.GetPrivateKey());
return key;
}
internal async Task Connect() {
await this.WebSocket.ConnectAsync(new Uri("wss://extrachat.annaclemens.io/"), CancellationToken.None);
}
internal Task AuthenticateAndList() {
return Task.Run(async () => {
if (await this.Authenticate()) {
this._wasConnected = true;
this.Plugin.ChatGui.PrintChat(new XivChatEntry {
Message = "Connected to ExtraChat.",
Type = XivChatType.Notice,
});
await this.ListAll();
}
});
}
/// <summary>
/// Gets the challenge to put in the user's Lodestone profile.
/// </summary>
/// <returns>challenge or null if LocalPlayer is null</returns>
/// <exception cref="Exception">if the server returns an error or unexpected output</exception>
internal async Task<string?> GetChallenge() {
if (this.Plugin.LocalPlayer is not { } player) {
return null;
}
this.Status = State.RetrievingChallenge;
var response = await this.QueueMessageAndWait(new RequestKind.Register(new RegisterRequest {
Name = player.Name.TextValue,
World = (ushort) player.HomeWorld.Id,
ChallengeCompleted = false,
}));
switch (response) {
case ResponseKind.Error { Response.Error: var error }:
this.Status = State.NotAuthenticated;
throw new Exception(error);
case ResponseKind.Register { Response: RegisterResponse.Challenge { Text: var challenge } }:
this.Status = State.WaitingForVerification;
return challenge;
default:
this.Status = State.NotAuthenticated;
throw new Exception("Unexpected response");
}
}
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 response = await this.QueueMessageAndWait(new RequestKind.Create(new CreateRequest {
Name = encryptedName,
}));
var channelInfo = response switch {
ResponseKind.Error { Response.Error: var error } => throw new Exception(error),
ResponseKind.Create { Response.Channel: var channel } => (channel, shared),
_ => throw new Exception("invalid response"),
};
this.Plugin.ConfigInfo.RegisterChannel(channelInfo.channel, channelInfo.shared);
this.Channels[channelInfo.channel.Id] = channelInfo.channel;
this.ChannelRanks[channelInfo.channel.Id] = Rank.Admin;
this.Plugin.Commands.ReregisterAll();
this.Plugin.SaveConfig();
return channelInfo;
}
internal async Task<InviteResponse?> Invite(string name, ushort world, Guid channel) {
// Invite requires three steps:
// 1. Get the public key of the invitee
// 2. Encrypt the shared key with the public key
// NOTE: in all cases, the party initiating the key exchange is
// considered the CLIENT
// 3. Send the invite with the encrypted shared key
// 0. Get the channel shared key
if (!this.Plugin.ConfigInfo.Channels.TryGetValue(channel, out var channelInfo)) {
return null;
}
// 1. Get the public key of the invitee
var response = await this.QueueMessageAndWait(new RequestKind.PublicKey(new PublicKeyRequest {
Name = name,
World = world,
}));
var invitee = response switch {
ResponseKind.Error { Response.Error: var error } => throw new Exception(error),
ResponseKind.PublicKey { Response.PublicKey: var respKey } => respKey,
_ => throw new Exception("invalid response"),
};
if (invitee == null) {
return null;
}
// 2. Encrypt the shared key with the public key
var kx = SodiumKeyExchange.CalculateClientSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), invitee);
var encryptedShared = SecretBox.Encrypt(kx.TransferSharedSecret, channelInfo.SharedSecret);
// 3. Send the invite with the encrypted shared key
response = await this.QueueMessageAndWait(new RequestKind.Invite(new InviteRequest {
Channel = channel,
Name = name,
World = world,
EncryptedSecret = encryptedShared,
}));
return response switch {
ResponseKind.Error { Response.Error: var error } => throw new Exception(error),
ResponseKind.Invite { Response: var invite } => invite,
_ => throw new Exception("Unexpected response"),
};
}
internal async Task InviteToast(string name, ushort world, Guid channel) {
var worldName = WorldUtil.WorldName(world);
var channelName = this.Plugin.ConfigInfo.GetName(channel);
try {
if (await this.Invite(name, world, channel) == null) {
this.Plugin.ShowError($"Could not invite {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\": not logged into ExtraChat");
} else {
this.Plugin.ShowInfo($"Invited {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\"");
}
} catch (Exception ex) {
this.Plugin.ShowError($"Could not invite {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\": {ex.Message}");
}
}
/// <summary>
/// Attempts to register the user after the challenge has been completed.
/// </summary>
/// <returns>authentication key or null if LocalPlayer was null or the challenge failed</returns>
/// <exception cref="Exception">if the server returns an error or unexpected output</exception>
internal async Task<string?> Register() {
if (this.Plugin.LocalPlayer is not { } player) {
return null;
}
this.Status = State.Verifying;
var response = await this.QueueMessageAndWait(new RequestKind.Register(new RegisterRequest {
Name = player.Name.TextValue,
World = (ushort) player.HomeWorld.Id,
ChallengeCompleted = true,
}));
switch (response) {
case ResponseKind.Error { Response.Error: var error }:
this.Status = State.WaitingForVerification;
throw new Exception(error);
case ResponseKind.Register { Response: RegisterResponse.Failure }:
this.Status = State.WaitingForVerification;
return null;
case ResponseKind.Register { Response: RegisterResponse.Success { Key: var key } }:
this.Status = State.NotAuthenticated;
return key;
default:
throw new Exception("Unexpected response");
}
}
internal async Task<bool> Authenticate() {
if (this.Plugin.ConfigInfo.Key is not { } key) {
return false;
}
this.Status = State.Authenticating;
var response = await this.QueueMessageAndWait(new RequestKind.Authenticate(new AuthenticateRequest {
Key = key,
PublicKey = this.KeyPair.GetPublicKey(),
}));
var success = response switch {
ResponseKind.Error => false,
ResponseKind.Authenticate { Response.Error: null } => true,
ResponseKind.Authenticate => false,
_ => false,
};
this.Status = success ? State.Connected : State.FailedAuthentication;
return success;
}
internal async Task SendMessage(Guid channel, byte[] message) {
await this.QueueMessage(new RequestKind.Message(new MessageRequest {
Channel = channel,
Message = message,
}));
}
internal async Task ListAll() {
await this.QueueMessage(new RequestKind.List(new ListRequest.All()));
}
internal async Task ListMembers(Guid channelId) {
await this.QueueMessage(new RequestKind.List(new ListRequest.Members(channelId)));
}
internal async Task Join(Guid channelId) {
if (!this.Plugin.ConfigInfo.Channels.TryGetValue(channelId, out var info)) {
return;
}
var response = await this.QueueMessageAndWait(new RequestKind.Join(new JoinRequest {
Channel = channelId,
}));
switch (response) {
case ResponseKind.Error { Response.Error: var error }: {
this.Plugin.ShowError($"Failed to join \"{info.Name}\": {error}");
break;
}
case ResponseKind.Join { Response: var resp }: {
this.Plugin.ShowInfo($"Joined \"{info.Name}\"");
this.InvitedChannels.Remove(channelId);
this.Channels[channelId] = resp.Channel;
this.ChannelRanks[channelId] = Rank.Member;
this.Plugin.ConfigInfo.AddChannelIndex(resp.Channel.Id);
this.Plugin.ConfigInfo.UpdateChannel(resp.Channel);
this.Plugin.SaveConfig();
this.Plugin.Commands.ReregisterAll();
break;
}
default: {
throw new Exception("Unexpected response");
}
}
}
internal async Task Leave(Guid channelId) {
var response = await this.QueueMessageAndWait(new RequestKind.Leave(new LeaveRequest {
Channel = channelId,
}));
if (response is ResponseKind.Leave { Response: { Error: null, Channel: var id } }) {
this.ActuallyLeave(id);
}
}
private void ActuallyLeave(Guid id) {
this.Channels.Remove(id);
this.InvitedChannels.Remove(id);
var idx = this.Plugin.ConfigInfo.ChannelOrder
.Select(entry => (entry.Key, entry.Value))
.FirstOrDefault(entry => entry.Value == id);
if (idx != default) {
this.Plugin.ConfigInfo.ChannelOrder.Remove(idx.Key);
this.Plugin.SaveConfig();
}
}
internal async Task<string?> Kick(Guid id, string name, ushort world) {
var response = await this.QueueMessageAndWait(new RequestKind.Kick(new KickRequest {
Channel = id,
Name = name,
World = world,
}));
return response switch {
ResponseKind.Error { Response.Error: var error } => error,
_ => null,
};
}
internal async Task<string?> Promote(Guid id, string name, ushort world, Rank rank) {
var resp = await this.QueueMessageAndWait(new RequestKind.Promote(new PromoteRequest {
Channel = id,
Name = name,
World = world,
Rank = rank,
}));
return resp switch {
ResponseKind.Error { Response.Error: var error } => error,
_ => null,
};
}
internal async Task<string?> Disband(Guid id) {
var resp = await this.QueueMessageAndWait(new RequestKind.Disband(new DisbandRequest {
Channel = id,
}));
return resp switch {
ResponseKind.Error { Response.Error: var error } => error,
_ => null,
};
}
internal async Task<string?> Update(Guid id, UpdateKind kind) {
var resp = await this.QueueMessageAndWait(new RequestKind.Update(new UpdateRequest {
Channel = id,
Kind = kind,
}));
return resp switch {
ResponseKind.Error { Response.Error: var error } => error,
ResponseKind.Update => null,
_ => throw new Exception("Unexpected response"),
};
}
internal async Task UpdateToast(Guid id, UpdateKind kind) {
if (await this.Update(id, kind) is not { } error) {
return;
}
var name = this.Plugin.ConfigInfo.GetName(id);
this.Plugin.ShowError($"Could not update \"{name}\": {error}");
}
internal async Task RequestSecrets(Guid id) {
await this.QueueMessage(new RequestKind.Secrets(new SecretsRequest {
Channel = id,
}));
}
private bool _up;
#pragma warning disable CS4014
private async Task Loop() {
Start:
this._wasConnected = false;
this._up = false;
this._number = 1;
this.WebSocket.Abort();
this.Status = State.Disconnected;
if (!this._active) {
return;
}
this.ToSend = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter<ChannelReader<ResponseKind>>?)>();
this._waitersMutex.WaitOne();
this.Waiters = new Dictionary<uint, ChannelWriter<ResponseKind>>();
this._waitersMutex.ReleaseMutex();
// If the websocket is closed, we need to reconnect
this.WebSocket.Dispose();
this.WebSocket = new ClientWebSocket();
this.Status = State.Connecting;
await this.Connect();
Task.Run(async () => {
while (this._active && !this._up) {
await this.WebSocket.SendMessage(new RequestContainer {
Number = IsUpPingNumber,
Kind = new RequestKind.Ping(new PingRequest()),
});
await Task.Delay(TimeSpan.FromSeconds(1));
}
if (this._active && this.Plugin.ConfigInfo.Key != null) {
this.AuthenticateAndList();
}
});
if (this.Plugin.ConfigInfo.Key == null) {
this.Status = State.NotAuthenticated;
}
var websocketMessage = this.WebSocket.ReceiveMessage();
var toSend = this.ToSend.Reader.ReadAsync().AsTask();
while (this._active && this.WebSocket.State == WebSocketState.Open) {
var finished = await Task.WhenAny(websocketMessage, toSend);
if (finished == websocketMessage) {
var response = await websocketMessage;
websocketMessage = this.WebSocket.ReceiveMessage();
switch (response) {
case { Kind: ResponseKind.Ping, Number: IsUpPingNumber } when !this._up: {
this._up = true;
break;
}
case { Kind: ResponseKind.Message { Response: var resp } }: {
Task.Run(() => this.HandleMessage(resp));
break;
}
case { Kind: ResponseKind.Invited { Response: var resp } }: {
Task.Run(() => this.HandleInvited(resp));
break;
}
case { Kind: ResponseKind.List { Response: var resp } }: {
Task.Run(() => this.HandleList(resp));
break;
}
case { Kind: ResponseKind.MemberChange { Response: var resp } }: {
Task.Run(() => this.HandleMemberChange(resp));
break;
}
case { Kind: ResponseKind.Disband { Response: var resp }, Number: 0 }: {
// this is a disband notification, not a response to a command
Task.Run(() => this.HandleDisband(resp));
break;
}
case { Kind: ResponseKind.Updated { Response: var resp }, Number: 0 }: {
Task.Run(() => this.HandleUpdated(resp));
break;
}
case { Kind: ResponseKind.Secrets { Response: var resp } }: {
Task.Run(() => this.HandleSecrets(resp));
break;
}
case { Kind: ResponseKind.SendSecrets { Response: var resp }, Number: 0 }: {
Task.Run(async () => await this.HandleSendSecrets(resp));
break;
}
default: {
this._waitersMutex.WaitOne();
try {
if (this.Waiters.Remove(response.Number, out var waiter)) {
await waiter.WriteAsync(response.Kind);
}
} finally {
this._waitersMutex.ReleaseMutex();
}
break;
}
}
} else if (finished == toSend) {
var (req, update) = await toSend;
toSend = this.ToSend.Reader.ReadAsync().AsTask();
await this.WebSocket.SendMessage(req);
if (update != null) {
await update.WriteAsync(this.RegisterWaiter(req.Number));
}
}
}
await Task.Delay(TimeSpan.FromSeconds(3));
goto Start;
// ReSharper disable once FunctionNeverReturns
}
#pragma warning restore CS4014
private void HandleSecrets(SecretsResponse resp) {
var kx = SodiumKeyExchange.CalculateClientSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), resp.PublicKey);
var shared = SecretBox.Decrypt(kx.ReadSharedSecret, resp.EncryptedSharedSecret);
this.Plugin.ConfigInfo.GetOrInsertChannel(resp.Channel).SharedSecret = shared;
this.Plugin.SaveConfig();
}
private async Task HandleSendSecrets(SendSecretsResponse resp) {
if (!this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info) || info.SharedSecret.Length == 0) {
await this.QueueMessage(new RequestKind.SendSecrets(new SendSecretsRequest {
RequestId = resp.RequestId,
EncryptedSharedSecret = null,
}));
return;
}
var kx = SodiumKeyExchange.CalculateServerSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), resp.PublicKey);
var encrypted = SecretBox.Encrypt(kx.TransferSharedSecret, info.SharedSecret);
await this.QueueMessage(new RequestKind.SendSecrets(new SendSecretsRequest {
RequestId = resp.RequestId,
EncryptedSharedSecret = encrypted,
}));
}
private void HandleUpdated(UpdatedResponse resp) {
switch (resp.Kind) {
case UpdateKind.Name name: {
if (this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info)) {
var newName = Encoding.UTF8.GetString(SecretBox.Decrypt(info.SharedSecret, name.NewName));
info.Name = newName;
this.Plugin.SaveConfig();
}
break;
}
default: {
PluginLog.LogWarning($"Unhandled update kind: {resp.Kind}");
break;
}
}
}
private void HandleMemberChange(MemberChangeResponse resp) {
if (!this.TryGetChannel(resp.Channel, out var channel)) {
return;
}
var channelName = this.Plugin.ConfigInfo.GetName(resp.Channel);
var self = this.Plugin.LocalPlayer;
var isSelf = self?.Name.TextValue == resp.Name && self.HomeWorld.Id == resp.World;
switch (resp.Kind) {
case MemberChangeKind.Invite: {
channel.Members.Add(new Member {
Name = resp.Name,
World = resp.World,
Rank = Rank.Invited,
Online = true,
});
break;
}
case MemberChangeKind.InviteCancel: {
channel.Members.RemoveAll(
member => member.Name == resp.Name
&& member.World == resp.World
&& member.Rank == Rank.Invited
);
if (isSelf) {
this.ChannelRanks.Remove(resp.Channel);
this.InvitedChannels.Remove(resp.Channel);
}
break;
}
case MemberChangeKind.InviteDecline: {
channel.Members.RemoveAll(
member => member.Name == resp.Name
&& member.World == resp.World
&& member.Rank == Rank.Invited
);
if (isSelf) {
this.ChannelRanks.Remove(resp.Channel);
this.InvitedChannels.Remove(resp.Channel);
}
break;
}
case MemberChangeKind.Join: {
var member = channel.Members.FirstOrDefault(member => member.Name == resp.Name && member.World == resp.World);
if (member != null) {
member.Rank = Rank.Member;
} else {
channel.Members.Add(new Member {
Name = resp.Name,
World = resp.World,
Rank = Rank.Member,
});
}
if (isSelf) {
this.ChannelRanks[resp.Channel] = Rank.Member;
this.Plugin.ShowInfo($"You have joined \"{channelName}\"");
} else {
var worldName = WorldUtil.WorldName(resp.World);
this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has joined \"{channelName}\"");
}
break;
}
case MemberChangeKind.Kick: {
channel.Members.RemoveAll(member => member.Name == resp.Name && member.World == resp.World);
if (isSelf) {
this.ChannelRanks.Remove(resp.Channel);
this.Plugin.ConfigInfo.RemoveChannelIndex(resp.Channel);
this.Plugin.SaveConfig();
this.Plugin.ShowInfo($"You have been kicked from \"{channelName}\"");
} else {
var worldName = WorldUtil.WorldName(resp.World);
this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has been kicked from \"{channelName}\"");
}
break;
}
case MemberChangeKind.Leave: {
channel.Members.RemoveAll(member => member.Name == resp.Name && member.World == resp.World);
if (isSelf) {
this.ChannelRanks.Remove(resp.Channel);
this.Plugin.ShowInfo($"You have left \"{channelName}\"");
} else {
var worldName = WorldUtil.WorldName(resp.World);
this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has left \"{channelName}\"");
}
break;
}
case MemberChangeKind.Promote promote: {
bool wasPromotion;
var member = channel.Members.FirstOrDefault(member => member.Name == resp.Name && member.World == resp.World);
if (member != null) {
wasPromotion = promote.Rank >= member.Rank;
member.Rank = promote.Rank;
} else {
wasPromotion = true;
channel.Members.Add(new Member {
Name = resp.Name,
World = resp.World,
Rank = promote.Rank,
});
}
var verb = wasPromotion ? "promoted" : "demoted";
if (isSelf) {
this.ChannelRanks[resp.Channel] = promote.Rank;
this.Plugin.ShowInfo($"You have been {verb} to {promote.Rank} in \"{channelName}\"");
} else {
var worldName = WorldUtil.WorldName(resp.World);
this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has been {verb} to {promote.Rank} in \"{channelName}\"");
}
break;
}
default: {
throw new ArgumentOutOfRangeException();
}
}
}
private void HandleDisband(DisbandResponse resp) {
if (this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info)) {
this.Plugin.ShowInfo($"\"{info.Name}\" has been disbanded.");
}
this.ActuallyLeave(resp.Channel);
}
private void HandleList(ListResponse resp) {
var self = this.Plugin.LocalPlayer;
switch (resp) {
case ListResponse.All all: {
this.Channels.Clear();
this.InvitedChannels.Clear();
foreach (var channel in all.AllChannels) {
this.Channels[channel.Id] = channel;
var member = channel.Members
.FirstOrDefault(member => member.Name == self?.Name.TextValue
&& member.World == self.HomeWorld.Id);
this.ChannelRanks.Remove(channel.Id);
if (member != null) {
this.ChannelRanks[channel.Id] = member.Rank;
}
this.Plugin.ConfigInfo.UpdateChannel(channel);
}
foreach (var channel in all.AllInvites) {
this.InvitedChannels[channel.Id] = channel;
this.ChannelRanks[channel.Id] = Rank.Invited;
this.Plugin.ConfigInfo.UpdateChannel(channel);
this.Plugin.SaveConfig();
}
this.Plugin.SaveConfig();
break;
}
case ListResponse.Channels channels: {
foreach (var channel in channels.SimpleChannels) {
this.Channels[channel.Id] = new Channel {
Id = channel.Id,
Name = channel.Name,
Members = new List<Member>(),
};
this.ChannelRanks[channel.Id] = channel.Rank;
this.Plugin.ConfigInfo.UpdateChannel(channel);
}
this.Plugin.SaveConfig();
break;
}
case ListResponse.Invites invites: {
foreach (var channel in invites.AllInvites) {
this.InvitedChannels[channel.Id] = new Channel {
Id = channel.Id,
Name = channel.Name,
Members = new List<Member>(),
};
this.ChannelRanks[channel.Id] = channel.Rank;
this.Plugin.ConfigInfo.UpdateChannel(channel);
}
this.Plugin.SaveConfig();
break;
}
case ListResponse.Members members: {
if (!this.Channels.TryGetValue(members.ChannelId, out var channel)) {
break;
}
channel.Members = members.AllMembers.ToList();
var member = channel.Members
.FirstOrDefault(member => member.Name == self?.Name.TextValue
&& member.World == self.HomeWorld.Id);
this.ChannelRanks.Remove(channel.Id);
if (member != null) {
this.ChannelRanks[channel.Id] = member.Rank;
}
break;
}
}
this.Plugin.Commands.ReregisterAll();
}
private void HandleMessage(MessageResponse resp) {
var config = this.Plugin.ConfigInfo;
if (!config.Channels.TryGetValue(resp.Channel, out var info)) {
return;
}
var message = SeString.Parse(SecretBox.Decrypt(info.SharedSecret, resp.Message));
var output = new SeStringBuilder();
var colour = config.GetUiColour(resp.Channel);
output.AddUiForeground(colour);
var marker = config.GetMarker(resp.Channel) ?? "ECLS?";
var isSelf = resp.Sender == this.Plugin.LocalPlayer?.Name.TextValue && resp.World == this.Plugin.LocalPlayer?.HomeWorld.Id;
output.AddText($"[{marker}]<");
if (isSelf) {
output.AddText(resp.Sender);
} else {
output.Add(new PlayerPayload(resp.Sender, resp.World));
}
if (!isSelf && resp.World != this.Plugin.LocalPlayer?.CurrentWorld.Id) {
output.AddIcon(BitmapFontIcon.CrossWorld);
var world = this.Plugin.DataManager.GetExcelSheet<World>()?.GetRow(resp.World)?.Name.ToDalamudString();
if (world != null) {
foreach (var payload in world.Payloads) {
output.Add(payload);
}
} else {
output.AddText($"[Unknown {resp.World}]");
}
}
output.AddText("> ");
foreach (var payload in message.Payloads) {
output.Add(payload);
}
output.AddUiForegroundOff();
if (!this.Plugin.ConfigInfo.ChannelChannels.TryGetValue(resp.Channel, out var outputChannel)) {
outputChannel = XivChatType.Debug;
}
this.Plugin.ChatGui.PrintChat(new XivChatEntry {
Message = output.Build(),
Name = isSelf
? resp.Sender
: new SeString(new PlayerPayload(resp.Sender, resp.World)),
Type = outputChannel,
});
}
private void HandleInvited(InvitedResponse info) {
// 1. Decrypt the shared key
// 2. Decrypt the channel name
var inviter = info.PublicKey;
var kx = SodiumKeyExchange.CalculateServerSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), inviter);
var shared = SecretBox.Decrypt(kx.ReadSharedSecret, info.EncryptedSecret);
var name = Encoding.UTF8.GetString(SecretBox.Decrypt(shared, info.Channel.Name));
this.Plugin.ConfigInfo.Channels[info.Channel.Id] = new ChannelInfo {
Name = name,
SharedSecret = shared,
};
this.InvitedChannels[info.Channel.Id] = info.Channel;
this.ChannelRanks[info.Channel.Id] = Rank.Invited;
this.Plugin.SaveConfig();
this.Plugin.ShowInfo($"Invited to join \"{name}\" by {info.Name}{PluginUi.CrossWorld}{WorldUtil.WorldName(info.World)}");
}
}

View File

@ -0,0 +1,100 @@
using Dalamud.Game.Command;
using Dalamud.Logging;
using ExtraChat.Util;
namespace ExtraChat;
internal class Commands : IDisposable {
private static readonly string[] MainCommands = {
"/extrachat",
"/ec",
"/eclcmd",
};
private Plugin Plugin { get; }
private Dictionary<string, Guid> RegisteredInternal { get; } = new();
internal IReadOnlyDictionary<string, Guid> Registered => this.RegisteredInternal;
internal Commands(Plugin plugin) {
this.Plugin = plugin;
this.Plugin.ClientState.Logout += this.OnLogout;
this.RegisterMain();
this.RegisterAll();
}
private void OnLogout(object? sender, EventArgs e) {
this.UnregisterAll();
}
private void RegisterMain() {
foreach (var command in MainCommands) {
this.Plugin.CommandManager.AddHandler(command, new CommandInfo(this.MainCommand));
}
}
private void UnregisterMain() {
foreach (var command in MainCommands) {
this.Plugin.CommandManager.RemoveHandler(command);
}
}
private void MainCommand(string command, string arguments) {
this.Plugin.PluginUi.Visible ^= true;
}
internal void ReregisterAll() {
this.UnregisterAll();
this.RegisterAll();
this.Plugin.Ipc.BroadcastChannelCommandColours();
}
internal void RegisterAll() {
var info = this.Plugin.ConfigInfo;
foreach (var (idx, id) in info.ChannelOrder) {
this.RegisterOne($"/ecl{idx + 1}", id);
}
foreach (var (alias, id) in info.Aliases) {
this.RegisterOne(alias, id);
}
}
internal void UnregisterAll() {
foreach (var command in this.Registered.Keys) {
this.Plugin.CommandManager.RemoveHandler(command);
}
this.RegisteredInternal.Clear();
}
private void RegisterOne(string command, Guid id) {
this.RegisteredInternal[command] = id;
void Handler(string _, string arguments) {
PluginLog.LogWarning("Command handler actually invoked");
}
this.Plugin.CommandManager.AddHandler(command, new CommandInfo(Handler) {
ShowInHelp = false,
});
}
internal void SendMessage(Guid id, byte[] bytes) {
if (!this.Plugin.ConfigInfo.Channels.TryGetValue(id, out var info)) {
this.Plugin.ChatGui.PrintError("ExtraChat Linkshell information could not be loaded.");
return;
}
var message = this.Plugin.GameFunctions.ResolvePayloads(bytes);
var ciphertext = SecretBox.Encrypt(info.SharedSecret, message);
Task.Run(async () => await this.Plugin.Client.SendMessage(id, ciphertext));
}
public void Dispose() {
this.UnregisterAll();
this.UnregisterMain();
this.Plugin.ClientState.Logout -= this.OnLogout;
}
}

View File

@ -0,0 +1,151 @@
using System.Text;
using Dalamud.Configuration;
using Dalamud.Game.Text;
using ExtraChat.Protocol.Channels;
using ExtraChat.Util;
namespace ExtraChat;
[Serializable]
internal class Configuration : IPluginConfiguration {
public int Version { get; set; } = 1;
public bool UseNativeToasts = true;
public XivChatType DefaultChannel = XivChatType.Debug;
public Dictionary<ulong, ConfigInfo> Configs { get; } = new();
internal ConfigInfo GetConfig(ulong id) {
if (id == 0) {
// just pretend
return new ConfigInfo();
}
if (this.Configs.TryGetValue(id, out var config)) {
return config;
}
var newConfig = new ConfigInfo();
this.Configs[id] = newConfig;
return newConfig;
}
}
[Serializable]
internal class ConfigInfo {
public string? Key;
public Dictionary<Guid, ChannelInfo> Channels = new();
public Dictionary<int, Guid> ChannelOrder = new();
public Dictionary<string, Guid> Aliases = new();
public Dictionary<Guid, ushort> ChannelColors = new();
public Dictionary<Guid, string> ChannelMarkers = new();
public Dictionary<Guid, XivChatType> ChannelChannels = new();
internal string GetName(Guid id) => this.Channels.TryGetValue(id, out var channel)
? channel.Name
: "???";
internal ushort GetUiColour(Guid id) => this.ChannelColors.TryGetValue(id, out var colour)
? colour
: Plugin.DefaultColour;
internal string? GetMarker(Guid id) {
var order = this.GetOrder(id);
if (order == null) {
return null;
}
return this.ChannelMarkers.TryGetValue(id, out var custom)
? custom
: $"ECLS{order.Value + 1}";
}
internal int? GetOrder(Guid id) {
var pair = this.ChannelOrder
.Select(entry => (entry.Key, entry.Value))
.FirstOrDefault(entry => entry.Value == id);
if (pair == default) {
return null;
}
return pair.Key;
}
internal string GetFullName(Guid id) {
var name = this.GetName(id);
var order = "?";
var orderEntry = this.ChannelOrder
.Select(entry => (entry.Key, entry.Value))
.FirstOrDefault(entry => entry.Value == id);
if (orderEntry != default) {
order = (orderEntry.Key + 1).ToString();
}
return $"ECLS [{order}]: {name}";
}
internal ChannelInfo GetOrInsertChannel(Guid id) {
if (this.Channels.TryGetValue(id, out var channel)) {
return channel;
}
var newChannel = new ChannelInfo();
this.Channels[id] = newChannel;
return newChannel;
}
internal int AddChannelIndex(Guid channelId) {
var existing = this.ChannelOrder
.Select(entry => (entry.Key, entry.Value))
.FirstOrDefault(entry => entry.Value == channelId);
if (existing != default) {
return existing.Key;
}
var indices = this.ChannelOrder.Keys;
var idx = indices.Count == 0 ? 0 : indices.Max() + 1;
this.ChannelOrder[idx] = channelId;
return idx;
}
internal void RemoveChannelIndex(Guid channelId) {
var idx = this.ChannelOrder
.Select(entry => (entry.Key, entry.Value))
.FirstOrDefault(entry => entry.Value == channelId);
if (idx != default) {
this.ChannelOrder.Remove(idx.Key);
}
}
internal void RegisterChannel(Channel channel, byte[] key) {
var name = channel.DecryptName(key);
this.Channels[channel.Id] = new ChannelInfo {
Name = name,
SharedSecret = key,
};
this.AddChannelIndex(channel.Id);
}
internal void UpdateChannel(Guid id, byte[] name) {
if (this.Channels.TryGetValue(id, out var info)) {
info.Name = Encoding.UTF8.GetString(SecretBox.Decrypt(info.SharedSecret, name));
}
}
internal void UpdateChannel(Channel channel) {
this.UpdateChannel(channel.Id, channel.Name);
}
internal void UpdateChannel(SimpleChannel channel) {
this.UpdateChannel(channel.Id, channel.Name);
}
}
[Serializable]
internal class ChannelInfo {
public byte[] SharedSecret = Array.Empty<byte>();
public string Name = "???";
}

34
client/ExtraChat/Ext.cs Normal file
View File

@ -0,0 +1,34 @@
using System.Net.WebSockets;
using ExtraChat.Protocol;
using MessagePack;
namespace ExtraChat;
public static class Ext {
public static string ToHexString(this IEnumerable<byte> bytes) {
return string.Join("", bytes.Select(b => b.ToString("x2")));
}
public static async Task SendMessage(this ClientWebSocket client, RequestContainer request) {
var bytes = MessagePackSerializer.Serialize(request);
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await client.SendAsync(bytes, WebSocketMessageType.Binary, true, cts.Token);
}
public static async Task<ResponseContainer> ReceiveMessage(this ClientWebSocket client) {
var bytes = new ArraySegment<byte>(new byte[2048]);
WebSocketReceiveResult result;
var i = 0;
do {
result = await client.ReceiveAsync(bytes[i..], CancellationToken.None);
i += result.Count;
if (i >= bytes.Count) {
throw new Exception();
}
} while (!result.EndOfMessage);
return MessagePackSerializer.Deserialize<ResponseContainer>(bytes[..i]);
}
}

View File

@ -0,0 +1,64 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.3</Version>
<TargetFramework>net5.0-windows</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<DebugType>full</DebugType>
<DebugSymbols>true</DebugSymbols>
</PropertyGroup>
<PropertyGroup>
<Dalamud>$(AppData)\XIVLauncher\addon\Hooks\dev</Dalamud>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
<Dalamud>$(DALAMUD_HOME)</Dalamud>
</PropertyGroup>
<PropertyGroup Condition="'$(IsCI)' == 'true'">
<Dalamud>$(HOME)/dalamud</Dalamud>
</PropertyGroup>
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>$(Dalamud)\Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(Dalamud)\FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(Dalamud)\ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(Dalamud)\ImGuiScene.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(Dalamud)\Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(Dalamud)\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" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,5 @@
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.'

View File

@ -0,0 +1,26 @@
using System.Buffers;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class BinaryUuidFormatter : IMessagePackFormatter<Guid> {
public void Serialize(ref MessagePackWriter writer, Guid value, MessagePackSerializerOptions options) {
var bytes = value.ToByteArray();
FlipBytes(bytes);
writer.Write(bytes);
}
public Guid Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
var bytes = reader.ReadBytes()!.Value.ToArray();
FlipBytes(bytes);
return new Guid(bytes);
}
internal static void FlipBytes(byte[] bytes) {
// microsoft is stupid for no reason
Array.Reverse(bytes,0,4);
Array.Reverse(bytes,4,2);
Array.Reverse(bytes,6,2);
}
}

View File

@ -0,0 +1,28 @@
using System.Buffers;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class BinaryUuidNullableFormatter : IMessagePackFormatter<Guid?> {
public void Serialize(ref MessagePackWriter writer, Guid? value, MessagePackSerializerOptions options) {
if (!value.HasValue) {
writer.WriteNil();
return;
}
var bytes = value.Value.ToByteArray();
BinaryUuidFormatter.FlipBytes(bytes);
writer.Write(bytes);
}
public Guid? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
var bytes = reader.ReadBytes()?.ToArray();
if (bytes == null) {
return null;
}
BinaryUuidFormatter.FlipBytes(bytes);
return new Guid(bytes);
}
}

View File

@ -0,0 +1,41 @@
using System.Text;
using ExtraChat.Protocol;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class ListRequestFormatter : IMessagePackFormatter<ListRequest> {
public void Serialize(ref MessagePackWriter writer, ListRequest value, MessagePackSerializerOptions options) {
var plain = value switch {
ListRequest.All => "all",
ListRequest.Channels => "channels",
ListRequest.Invites => "invites",
_ => null,
};
if (plain != null) {
writer.WriteString(Encoding.UTF8.GetBytes(plain));
return;
}
writer.WriteMapHeader(1);
switch (value) {
case ListRequest.Members members: {
writer.WriteString(Encoding.UTF8.GetBytes("members"));
new BinaryUuidFormatter().Serialize(ref writer, members.ChannelId, options);
break;
}
default: {
throw new MessagePackSerializationException("Invalid ListRequest value");
}
}
}
public ListRequest Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
// TODO
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,56 @@
using ExtraChat.Protocol;
using ExtraChat.Protocol.Channels;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class ListResponseFormatter : IMessagePackFormatter<ListResponse> {
public void Serialize(ref MessagePackWriter writer, ListResponse value, MessagePackSerializerOptions options) {
// TODO
throw new NotImplementedException();
}
public ListResponse Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
if (reader.ReadMapHeader() != 1) {
throw new MessagePackSerializationException("Invalid map length");
}
var key = reader.ReadString();
switch (key) {
case "all": {
if (reader.ReadArrayHeader() != 2) {
throw new MessagePackSerializationException("Invalid map length");
}
var channels = options.Resolver.GetFormatter<Channel[]>().Deserialize(ref reader, options);
var invites = options.Resolver.GetFormatter<Channel[]>().Deserialize(ref reader, options);
return new ListResponse.All(channels, invites);
}
case "channels": {
var channels = options.Resolver.GetFormatter<SimpleChannel[]>().Deserialize(ref reader, options);
return new ListResponse.Channels(channels);
}
case "members": {
if (reader.ReadArrayHeader() != 2) {
throw new MessagePackSerializationException("Invalid map length");
}
var id = new BinaryUuidFormatter().Deserialize(ref reader, options);
var members = options.Resolver.GetFormatter<Member[]>().Deserialize(ref reader, options);
return new ListResponse.Members(id, members);
}
case "invites": {
var channels = options.Resolver.GetFormatter<SimpleChannel[]>().Deserialize(ref reader, options);
return new ListResponse.Invites(channels);
}
default: {
throw new MessagePackSerializationException("Invalid list response type");
}
}
}
}

View File

@ -0,0 +1,69 @@
using ExtraChat.Protocol;
using ExtraChat.Protocol.Channels;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class MemberChangeKindFormatter : IMessagePackFormatter<MemberChangeKind> {
public void Serialize(ref MessagePackWriter writer, MemberChangeKind value, MessagePackSerializerOptions options) {
throw new NotImplementedException();
}
public MemberChangeKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
if (reader.NextMessagePackType == MessagePackType.String) {
return reader.ReadString() switch {
"join" => new MemberChangeKind.Join(),
"leave" => new MemberChangeKind.Leave(),
"invite_decline" => new MemberChangeKind.InviteDecline(),
_ => throw new MessagePackSerializationException("invalid MemberChangeKind key"),
};
}
if (reader.ReadMapHeader() != 1) {
throw new MessagePackSerializationException("Invalid map length");
}
var key = reader.ReadString();
switch (key) {
case "invite": {
if (reader.ReadArrayHeader() != 2) {
throw new MessagePackSerializationException("Invalid array length");
}
var inviter = reader.ReadString();
var world = reader.ReadUInt16();
return new MemberChangeKind.Invite(inviter, world);
}
case "invite_cancel": {
if (reader.ReadArrayHeader() != 2) {
throw new MessagePackSerializationException("Invalid array length");
}
var canceler = reader.ReadString();
var world = reader.ReadUInt16();
return new MemberChangeKind.InviteCancel(canceler, world);
}
case "promote": {
if (reader.ReadArrayHeader() != 1) {
throw new MessagePackSerializationException("Invalid array length");
}
var rank = options.Resolver.GetFormatter<Rank>().Deserialize(ref reader, options);
return new MemberChangeKind.Promote(rank);
}
case "kick": {
if (reader.ReadArrayHeader() != 2) {
throw new MessagePackSerializationException("Invalid array length");
}
var kicker = reader.ReadString();
var world = reader.ReadUInt16();
return new MemberChangeKind.Kick(kicker, world);
}
default: {
throw new MessagePackSerializationException("invalid MemberChangeKind key");
}
}
}
}

View File

@ -0,0 +1,81 @@
using System.Text;
using ExtraChat.Protocol;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class RegisterResponseFormatter : IMessagePackFormatter<RegisterResponse> {
public void Serialize(ref MessagePackWriter writer, RegisterResponse value, MessagePackSerializerOptions options) {
if (value is RegisterResponse.Failure) {
writer.WriteString(Encoding.UTF8.GetBytes("failure"));
return;
}
writer.WriteMapHeader(1);
var key = value switch {
RegisterResponse.Challenge => "challenge",
RegisterResponse.Failure => "failure",
RegisterResponse.Success => "success",
_ => throw new ArgumentOutOfRangeException(nameof(value)),
};
writer.WriteString(Encoding.UTF8.GetBytes(key));
switch (value) {
case RegisterResponse.Challenge challenge: {
writer.WriteArrayHeader(1);
writer.WriteString(Encoding.UTF8.GetBytes(challenge.Text));
break;
}
case RegisterResponse.Failure:
break;
case RegisterResponse.Success success: {
writer.WriteArrayHeader(1);
writer.WriteString(Encoding.UTF8.GetBytes(success.Key));
break;
}
}
}
public RegisterResponse Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
if (reader.NextMessagePackType == MessagePackType.Map) {
if (reader.ReadMapHeader() != 1) {
throw new MessagePackSerializationException("Invalid map length");
}
} else if (reader.NextMessagePackType == MessagePackType.String) {
if (reader.ReadString() != "failure") {
throw new MessagePackSerializationException("Invalid RegisterResponse");
}
return new RegisterResponse.Failure();
} else {
throw new MessagePackSerializationException("Invalid RegisterResponse");
}
var key = reader.ReadString();
switch (key) {
case "challenge": {
if (reader.ReadArrayHeader() != 1) {
throw new MessagePackSerializationException("Invalid RegisterResponse");
}
var text = reader.ReadString();
return new RegisterResponse.Challenge(text);
}
case "failure":
throw new MessagePackSerializationException("Invalid RegisterResponse");
case "success": {
if (reader.ReadArrayHeader() != 1) {
throw new MessagePackSerializationException("Invalid RegisterResponse");
}
var text = reader.ReadString();
return new RegisterResponse.Success(text);
}
default:
throw new MessagePackSerializationException("Invalid RegisterResponse type");
}
}
}

View File

@ -0,0 +1,160 @@
using System.Text;
using ExtraChat.Protocol;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class RequestKindFormatter : IMessagePackFormatter<RequestKind> {
public void Serialize(ref MessagePackWriter writer, RequestKind value, MessagePackSerializerOptions options) {
writer.WriteMapHeader(1);
var key = value switch {
RequestKind.Ping => "ping",
RequestKind.Authenticate => "authenticate",
RequestKind.Create => "create",
RequestKind.Invite => "invite",
RequestKind.Join => "join",
RequestKind.Message => "message",
RequestKind.PublicKey => "public_key",
RequestKind.Register => "register",
RequestKind.List => "list",
RequestKind.Leave => "leave",
RequestKind.Kick => "kick",
RequestKind.Disband => "disband",
RequestKind.Promote => "promote",
RequestKind.Update => "update",
_ => throw new ArgumentOutOfRangeException(nameof(value)),
};
writer.WriteString(Encoding.UTF8.GetBytes(key));
switch (value) {
case RequestKind.Ping ping:
options.Resolver.GetFormatterWithVerify<PingRequest>().Serialize(ref writer, ping.Request, options);
break;
case RequestKind.Authenticate authenticate:
options.Resolver.GetFormatterWithVerify<AuthenticateRequest>().Serialize(ref writer, authenticate.Request, options);
break;
case RequestKind.Create create:
options.Resolver.GetFormatterWithVerify<CreateRequest>().Serialize(ref writer, create.Request, options);
break;
case RequestKind.Invite invite:
options.Resolver.GetFormatterWithVerify<InviteRequest>().Serialize(ref writer, invite.Request, options);
break;
case RequestKind.Join join:
options.Resolver.GetFormatterWithVerify<JoinRequest>().Serialize(ref writer, join.Request, options);
break;
case RequestKind.Message message:
options.Resolver.GetFormatterWithVerify<MessageRequest>().Serialize(ref writer, message.Request, options);
break;
case RequestKind.PublicKey publicKey:
options.Resolver.GetFormatterWithVerify<PublicKeyRequest>().Serialize(ref writer, publicKey.Request, options);
break;
case RequestKind.Register register:
options.Resolver.GetFormatterWithVerify<RegisterRequest>().Serialize(ref writer, register.Request, options);
break;
case RequestKind.List list:
options.Resolver.GetFormatterWithVerify<ListRequest>().Serialize(ref writer, list.Request, options);
break;
case RequestKind.Leave leave:
options.Resolver.GetFormatterWithVerify<LeaveRequest>().Serialize(ref writer, leave.Request, options);
break;
case RequestKind.Kick kick:
options.Resolver.GetFormatterWithVerify<KickRequest>().Serialize(ref writer, kick.Request, options);
break;
case RequestKind.Disband disband:
options.Resolver.GetFormatterWithVerify<DisbandRequest>().Serialize(ref writer, disband.Request, options);
break;
case RequestKind.Promote promote:
options.Resolver.GetFormatterWithVerify<PromoteRequest>().Serialize(ref writer, promote.Request, options);
break;
case RequestKind.Update update:
options.Resolver.GetFormatterWithVerify<UpdateRequest>().Serialize(ref writer, update.Request, options);
break;
case RequestKind.Secrets secrets:
options.Resolver.GetFormatterWithVerify<SecretsRequest>().Serialize(ref writer, secrets.Request, options);
break;
case RequestKind.SendSecrets sendSecrets:
options.Resolver.GetFormatterWithVerify<SendSecretsRequest>().Serialize(ref writer, sendSecrets.Request, options);
break;
}
}
public RequestKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
if (reader.ReadMapHeader() != 1) {
throw new MessagePackSerializationException("Invalid RequestKind");
}
var key = reader.ReadString();
switch (key) {
case "ping": {
var request = MessagePackSerializer.Deserialize<PingRequest>(ref reader, options);
return new RequestKind.Ping(request);
}
case "authenticate": {
var request = options.Resolver.GetFormatterWithVerify<AuthenticateRequest>().Deserialize(ref reader, options);
return new RequestKind.Authenticate(request);
}
case "create": {
var request = options.Resolver.GetFormatterWithVerify<CreateRequest>().Deserialize(ref reader, options);
return new RequestKind.Create(request);
}
case "invite": {
var request = options.Resolver.GetFormatterWithVerify<InviteRequest>().Deserialize(ref reader, options);
return new RequestKind.Invite(request);
}
case "join": {
var request = options.Resolver.GetFormatterWithVerify<JoinRequest>().Deserialize(ref reader, options);
return new RequestKind.Join(request);
}
case "message": {
var request = options.Resolver.GetFormatterWithVerify<MessageRequest>().Deserialize(ref reader, options);
return new RequestKind.Message(request);
}
case "public_key": {
var request = options.Resolver.GetFormatterWithVerify<PublicKeyRequest>().Deserialize(ref reader, options);
return new RequestKind.PublicKey(request);
}
case "register": {
var request = options.Resolver.GetFormatterWithVerify<RegisterRequest>().Deserialize(ref reader, options);
return new RequestKind.Register(request);
}
case "list": {
var request = options.Resolver.GetFormatterWithVerify<ListRequest>().Deserialize(ref reader, options);
return new RequestKind.List(request);
}
case "leave": {
var request = options.Resolver.GetFormatterWithVerify<LeaveRequest>().Deserialize(ref reader, options);
return new RequestKind.Leave(request);
}
case "kick": {
var request = options.Resolver.GetFormatterWithVerify<KickRequest>().Deserialize(ref reader, options);
return new RequestKind.Kick(request);
}
case "disband": {
var request = options.Resolver.GetFormatterWithVerify<DisbandRequest>().Deserialize(ref reader, options);
return new RequestKind.Disband(request);
}
case "promote": {
var request = options.Resolver.GetFormatterWithVerify<PromoteRequest>().Deserialize(ref reader, options);
return new RequestKind.Promote(request);
}
case "update": {
var request = options.Resolver.GetFormatterWithVerify<UpdateRequest>().Deserialize(ref reader, options);
return new RequestKind.Update(request);
}
case "secrets": {
var request = options.Resolver.GetFormatterWithVerify<SecretsRequest>().Deserialize(ref reader, options);
return new RequestKind.Secrets(request);
}
case "send_secrets": {
var request = options.Resolver.GetFormatterWithVerify<SendSecretsRequest>().Deserialize(ref reader, options);
return new RequestKind.SendSecrets(request);
}
default:
throw new MessagePackSerializationException("Invalid RequestKind");
}
}
}

View File

@ -0,0 +1,105 @@
using ExtraChat.Protocol;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class ResponseKindFormatter : IMessagePackFormatter<ResponseKind> {
public void Serialize(ref MessagePackWriter writer, ResponseKind value, MessagePackSerializerOptions options) {
// TODO
throw new NotImplementedException();
}
public ResponseKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
if (reader.ReadMapHeader() != 1) {
throw new MessagePackSerializationException("Invalid ResponseKind");
}
var key = reader.ReadString();
switch (key) {
case "ping": {
var response = options.Resolver.GetFormatterWithVerify<PingResponse>().Deserialize(ref reader, options);
return new ResponseKind.Ping(response);
}
case "error": {
var response = options.Resolver.GetFormatterWithVerify<ErrorResponse>().Deserialize(ref reader, options);
return new ResponseKind.Error(response);
}
case "authenticate": {
var response = options.Resolver.GetFormatterWithVerify<AuthenticateResponse>().Deserialize(ref reader, options);
return new ResponseKind.Authenticate(response);
}
case "create": {
var response = options.Resolver.GetFormatterWithVerify<CreateResponse>().Deserialize(ref reader, options);
return new ResponseKind.Create(response);
}
case "invite": {
var response = options.Resolver.GetFormatterWithVerify<InviteResponse>().Deserialize(ref reader, options);
return new ResponseKind.Invite(response);
}
case "invited": {
var response = options.Resolver.GetFormatterWithVerify<InvitedResponse>().Deserialize(ref reader, options);
return new ResponseKind.Invited(response);
}
case "join": {
var response = options.Resolver.GetFormatterWithVerify<JoinResponse>().Deserialize(ref reader, options);
return new ResponseKind.Join(response);
}
case "message": {
var response = options.Resolver.GetFormatterWithVerify<MessageResponse>().Deserialize(ref reader, options);
return new ResponseKind.Message(response);
}
case "public_key": {
var response = options.Resolver.GetFormatterWithVerify<PublicKeyResponse>().Deserialize(ref reader, options);
return new ResponseKind.PublicKey(response);
}
case "register": {
var response = options.Resolver.GetFormatterWithVerify<RegisterResponse>().Deserialize(ref reader, options);
return new ResponseKind.Register(response);
}
case "list": {
var response = options.Resolver.GetFormatterWithVerify<ListResponse>().Deserialize(ref reader, options);
return new ResponseKind.List(response);
}
case "leave": {
var response = options.Resolver.GetFormatterWithVerify<LeaveResponse>().Deserialize(ref reader, options);
return new ResponseKind.Leave(response);
}
case "kick": {
var response = options.Resolver.GetFormatterWithVerify<KickResponse>().Deserialize(ref reader, options);
return new ResponseKind.Kick(response);
}
case "disband": {
var response = options.Resolver.GetFormatterWithVerify<DisbandResponse>().Deserialize(ref reader, options);
return new ResponseKind.Disband(response);
}
case "promote": {
var response = options.Resolver.GetFormatterWithVerify<PromoteResponse>().Deserialize(ref reader, options);
return new ResponseKind.Promote(response);
}
case "member_change": {
var response = options.Resolver.GetFormatterWithVerify<MemberChangeResponse>().Deserialize(ref reader, options);
return new ResponseKind.MemberChange(response);
}
case "update": {
var response = options.Resolver.GetFormatterWithVerify<UpdateResponse>().Deserialize(ref reader, options);
return new ResponseKind.Update(response);
}
case "updated": {
var response = options.Resolver.GetFormatterWithVerify<UpdatedResponse>().Deserialize(ref reader, options);
return new ResponseKind.Updated(response);
}
case "secrets": {
var response = options.Resolver.GetFormatterWithVerify<SecretsResponse>().Deserialize(ref reader, options);
return new ResponseKind.Secrets(response);
}
case "send_secrets": {
var response = options.Resolver.GetFormatterWithVerify<SendSecretsResponse>().Deserialize(ref reader, options);
return new ResponseKind.SendSecrets(response);
}
default:
throw new MessagePackSerializationException("Invalid ResponseKind");
}
}
}

View File

@ -0,0 +1,47 @@
using System.Buffers;
using System.Text;
using ExtraChat.Protocol;
using MessagePack;
using MessagePack.Formatters;
namespace ExtraChat.Formatters;
public class UpdateKindFormatter : IMessagePackFormatter<UpdateKind> {
public void Serialize(ref MessagePackWriter writer, UpdateKind value, MessagePackSerializerOptions options) {
writer.WriteMapHeader(1);
var key = value switch {
UpdateKind.Name => "name",
_ => throw new ArgumentOutOfRangeException(nameof(value)),
};
writer.WriteString(Encoding.UTF8.GetBytes(key));
switch (value) {
case UpdateKind.Name name: {
writer.Write(name.NewName);
break;
}
default: {
throw new MessagePackSerializationException("Unknown UpdateKind");
}
}
}
public UpdateKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
if (reader.ReadMapHeader() != 1) {
throw new MessagePackSerializationException("UpdateKindFormatter: Invalid map length");
}
var key = reader.ReadString();
switch (key) {
case "name": {
var name = reader.ReadBytes()!.Value.ToArray();
return new UpdateKind.Name(name);
}
default: {
throw new MessagePackSerializationException("UpdateKindFormatter: Invalid key");
}
}
}
}

View File

@ -0,0 +1,268 @@
using System.Text;
using Dalamud.Hooking;
using Dalamud.Logging;
using Dalamud.Memory;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.System.Memory;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Lumina.Excel.GeneratedSheets;
namespace ExtraChat;
internal unsafe class GameFunctions : IDisposable {
private Plugin Plugin { get; }
// all this comes from 6.15: 751AF0
[Signature("4D 85 C0 74 08 45 8B C1")]
private readonly delegate* unmanaged<PronounModule*, Utf8String*, ulong, uint, Utf8String*> _resolvePayloads;
// [Signature("E8 ?? ?? ?? ?? 48 8B D0 48 8D 4D E0 E8 ?? ?? ?? ?? 41 B0 01")]
// private readonly delegate* unmanaged<PronounModule*, Utf8String*, Utf8String*> _step1;
[Signature("E8 ?? ?? ?? ?? 0F B7 7F 08")]
private readonly delegate* unmanaged<PronounModule*, Utf8String*, byte, Utf8String*> _step2;
[Signature("E8 ?? ?? ?? ?? 49 8B 45 00 49 8B CD FF 50 68")]
private readonly delegate* unmanaged<RaptureShellModule*, uint, void> _setChatChannel;
private delegate void SendMessageDelegate(IntPtr a1, Utf8String* message, IntPtr a3);
private delegate void SetChatChannelDelegate(RaptureShellModule* module, uint channel);
[Signature(
"E8 ?? ?? ?? ?? FE 86 ?? ?? ?? ?? C7 86",
DetourName = nameof(SendMessageDetour)
)]
private Hook<SendMessageDelegate> SendMessageHook { get; init; }
[Signature(
"E8 ?? ?? ?? ?? 49 8B 45 00 49 8B CD FF 50 68",
DetourName = nameof(SetChatChannelDetour)
)]
private Hook<SetChatChannelDelegate> SetChatChannelHook { get; init; }
private delegate IntPtr ChangeChannelNameDelegate(IntPtr agent);
[Signature(
"E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6",
DetourName = nameof(ChangeChannelNameDetour)
)]
private Hook<ChangeChannelNameDelegate> ChangeChannelNameHook { get; init; }
private delegate byte ShouldDoNameLookupDelegate(IntPtr agent);
[Signature(
"E8 ?? ?? ?? ?? 84 C0 75 1A 8B 93",
DetourName = nameof(ShouldDoNameLookupDetour)
)]
private Hook<ShouldDoNameLookupDelegate> ShouldDoNameLookupHook { get; init; }
private delegate ulong GetChatColourDelegate(IntPtr a1, int a2);
[Signature(
"E8 ?? ?? ?? ?? 48 8B 4B 10 B2 01 89 83",
DetourName = nameof(GetChatColourDetour)
)]
private Hook<GetChatColourDelegate> GetChatColourHook { get; init; }
[Obsolete("Use OverrideChannel")]
private Guid _overrideChannel = Guid.Empty;
#pragma warning disable CS0618
internal Guid OverrideChannel {
get => this._overrideChannel;
private set {
this._overrideChannel = value;
this.UpdateChat();
this.Plugin.Ipc.BroadcastOverride();
}
}
#pragma warning restore CS0618
private bool _shouldForceNameLookup;
internal GameFunctions(Plugin plugin) {
SignatureHelper.Initialise(this);
this.Plugin = plugin;
this.SendMessageHook!.Enable();
this.SetChatChannelHook!.Enable();
this.ChangeChannelNameHook!.Enable();
this.ShouldDoNameLookupHook!.Enable();
this.GetChatColourHook!.Enable();
}
public void Dispose() {
this.GetChatColourHook.Dispose();
this.ShouldDoNameLookupHook.Dispose();
this.ChangeChannelNameHook.Dispose();
this.SetChatChannelHook.Dispose();
this.SendMessageHook.Dispose();
}
internal void ResetOverride() {
this.OverrideChannel = Guid.Empty;
}
internal byte[] ResolvePayloads(byte[] input) {
if (input.Length == 0) {
return input;
}
var module = Framework.Instance()->GetUiModule()->GetPronounModule();
var memorySpace = IMemorySpace.GetDefaultSpace();
var str = memorySpace->Create<Utf8String>();
if (input[^1] != 0) {
var replacement = new byte[input.Length + 1];
input.CopyTo(replacement, 0);
replacement[^1] = 0;
input = replacement;
}
fixed (byte* bytesPtr = input) {
str->SetString(bytesPtr);
}
var postStep1 = this._resolvePayloads(module, str, 1, 0x3FF);
var postStep2 = this._step2(module, postStep1, 1);
var list = new List<byte>();
for (var i = 0; i < postStep2->BufUsed && postStep2->StringPtr[i] != 0; i++) {
list.Add(postStep2->StringPtr[i]);
}
str->Dtor();
IMemorySpace.Free(str);
// postStep1->Dtor();
// IMemorySpace.Free(postStep1);
// game dies if you do this
// postStep2->Dtor();
// IMemorySpace.Free(postStep2);
return list.ToArray();
}
private void SendMessageDetour(IntPtr a1, Utf8String* message, IntPtr a3) {
try {
if (this.SendMessageDetourInner(message)) {
this.SendMessageHook.Original(a1, message, a3);
}
} catch (Exception ex) {
PluginLog.LogError(ex, "Error in message detour");
}
}
/// <returns>true if the original function should be called</returns>
private bool SendMessageDetourInner(Utf8String* message) {
var sendTo = this.OverrideChannel;
byte[]? toSend = null;
if (message->StringPtr[0] == '/') {
sendTo = Guid.Empty;
var command = "";
int i;
for (i = 0; i < message->BufSize; i++) {
var c = message->StringPtr[i];
if (c == 0 || char.IsWhiteSpace((char) c)) {
break;
}
command += (char) c;
}
if (this.Plugin.Commands.Registered.TryGetValue(command, out var id)) {
var entireMessage = MemoryHelper.ReadRawNullTerminated((IntPtr) message->StringPtr);
sendTo = id;
if (entireMessage.Length - 1 >= i && char.IsWhiteSpace((char) entireMessage[i])) {
i += 1;
}
toSend = entireMessage[i..];
var isBlank = toSend.Length == 0 || toSend.All(c => char.IsWhiteSpace((char) c));
if (isBlank) {
this.OverrideChannel = id;
return false;
}
}
}
if (sendTo == Guid.Empty) {
return true;
}
toSend ??= MemoryHelper.ReadRawNullTerminated((IntPtr) message->StringPtr);
if (toSend.Length == 0 || toSend.All(c => char.IsWhiteSpace((char) c))) {
// don't send blank messages even to the original handler
return false;
}
this.Plugin.Commands.SendMessage(sendTo, toSend);
return false;
}
private void UpdateChat() {
this._shouldForceNameLookup = true;
var agent = Framework.Instance()->GetUiModule()->GetAgentModule()->GetAgentByInternalId(AgentId.ChatLog);
var update = (delegate* unmanaged<AgentInterface*, void>) ((void**) agent->VTable)[6];
update(agent);
}
private void SetChatChannelDetour(RaptureShellModule* module, uint channel) {
// avoid potential stack overflow from recursion
if (this.OverrideChannel != Guid.Empty) {
this.OverrideChannel = Guid.Empty;
}
this.SetChatChannelHook.Original(module, channel);
}
private IntPtr ChangeChannelNameDetour(IntPtr agent) {
var ret = this.ChangeChannelNameHook.Original(agent);
if (this.OverrideChannel == Guid.Empty) {
return ret;
}
var chatChannel = (Utf8String*) (agent + 0x48);
var name = this.Plugin.ConfigInfo.GetFullName(this.OverrideChannel);
fixed (byte* bytesPtr = Encoding.UTF8.GetBytes("\u3000 " + name + "\0")) {
chatChannel->SetString(bytesPtr);
}
return (IntPtr) chatChannel->StringPtr;
}
private byte ShouldDoNameLookupDetour(IntPtr agent) {
if (this._shouldForceNameLookup) {
this._shouldForceNameLookup = false;
return 1;
}
return this.ShouldDoNameLookupHook.Original(agent);
}
private ulong GetChatColourDetour(IntPtr a1, int a2) {
try {
if (this.OverrideChannel != Guid.Empty) {
var ui = this.Plugin.ConfigInfo.GetUiColour(this.OverrideChannel);
if (this.Plugin.DataManager.GetExcelSheet<UIColor>()?.GetRow(ui)?.UIForeground is { } colour) {
return colour >> 8;
}
}
} catch (Exception ex) {
PluginLog.LogError(ex, "Error in get chat colour detour");
}
return this.GetChatColourHook.Original(a1, a2);
}
}

65
client/ExtraChat/Ipc.cs Normal file
View File

@ -0,0 +1,65 @@
using Dalamud.Plugin.Ipc;
using Lumina.Excel.GeneratedSheets;
namespace ExtraChat;
internal class Ipc : IDisposable {
[Serializable]
private struct OverrideInfo {
public string? Channel;
public ushort UiColour;
public uint Rgba;
}
private Plugin Plugin { get; }
private ICallGateProvider<OverrideInfo, object> OverrideChannelColour { get; }
private ICallGateProvider<Dictionary<string, uint>, Dictionary<string, uint>> ChannelCommandColours { 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.ChannelCommandColours.RegisterFunc(_ => this.GetChannelColours());
}
public void Dispose() {
this.ChannelCommandColours.UnregisterFunc();
}
private Dictionary<string, uint> GetChannelColours() {
var dict = new Dictionary<string, uint>(this.Plugin.Commands.Registered.Count);
foreach (var (command, id) in this.Plugin.Commands.Registered) {
var colour = this.Plugin.ConfigInfo.GetUiColour(id);
if (this.Plugin.DataManager.GetExcelSheet<UIColor>()?.GetRow(colour)?.UIForeground is { } rgba) {
dict[command] = rgba;
}
}
return dict;
}
internal void BroadcastChannelCommandColours() {
this.ChannelCommandColours.SendMessage(this.GetChannelColours());
}
internal void BroadcastOverride() {
var over = this.Plugin.GameFunctions.OverrideChannel;
if (over == Guid.Empty) {
this.OverrideChannelColour.SendMessage(new OverrideInfo());
return;
}
var name = this.Plugin.ConfigInfo.GetFullName(over);
var colour = this.Plugin.ConfigInfo.GetUiColour(over);
var rgba = this.Plugin.DataManager.GetExcelSheet<UIColor>()?.GetRow(colour)?.UIForeground ?? 0;
this.OverrideChannelColour.SendMessage(new OverrideInfo {
Channel = name,
UiColour = colour,
Rgba = rgba,
});
}
}

155
client/ExtraChat/Plugin.cs Normal file
View File

@ -0,0 +1,155 @@
using ASodium;
using Dalamud.ContextMenu;
using Dalamud.Data;
using Dalamud.Game;
using Dalamud.Game.ClientState;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Command;
using Dalamud.Game.Gui;
using Dalamud.Game.Gui.Toast;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.IoC;
using Dalamud.Plugin;
using ExtraChat.Ui;
using ExtraChat.Util;
namespace ExtraChat;
// ReSharper disable once ClassNeverInstantiated.Global
public class Plugin : IDalamudPlugin {
internal const string PluginName = "ExtraChat";
internal const ushort DefaultColour = 578;
public string Name => PluginName;
[PluginService]
internal DalamudPluginInterface Interface { get; init; }
[PluginService]
internal ClientState ClientState { get; init; }
[PluginService]
internal CommandManager CommandManager { get; init; }
[PluginService]
internal ChatGui ChatGui { get; init; }
[PluginService]
internal DataManager DataManager { get; init; }
[PluginService]
internal Framework Framework { get; init; }
[PluginService]
internal GameGui GameGui { get; init; }
[PluginService]
internal ObjectTable ObjectTable { get; init; }
[PluginService]
internal TargetManager TargetManager { get; init; }
[PluginService]
private ToastGui ToastGui { get; init; }
internal Configuration Config { get; }
internal ConfigInfo ConfigInfo => this.Config.GetConfig(this.ClientState.LocalContentId);
internal Client Client { get; }
internal Commands Commands { get; }
internal PluginUi PluginUi { get; }
internal DalamudContextMenuBase ContextMenu { get; }
internal GameFunctions GameFunctions { get; }
internal Ipc Ipc { get; }
private PlayerCharacter? _localPlayer;
private readonly Mutex _localPlayerLock = new();
internal PlayerCharacter? LocalPlayer {
get {
this._localPlayerLock.WaitOne();
var player = this._localPlayer;
this._localPlayerLock.ReleaseMutex();
return player;
}
private set {
this._localPlayerLock.WaitOne();
this._localPlayer = value;
this._localPlayerLock.ReleaseMutex();
}
}
public Plugin() {
SodiumInit.Init();
WorldUtil.Initialise(this.DataManager!);
this.ContextMenu = new DalamudContextMenuBase();
this.Config = this.Interface!.GetPluginConfig() as Configuration ?? new Configuration();
this.Client = new Client(this);
this.Commands = new Commands(this);
this.PluginUi = new PluginUi(this);
this.GameFunctions = new GameFunctions(this);
this.Ipc = new Ipc(this);
this.Framework!.Update += this.FrameworkUpdate;
this.ContextMenu.Functions.ContextMenu.OnOpenGameObjectContextMenu += this.OnOpenGameObjectContextMenu;
}
public void Dispose() {
this.GameFunctions.ResetOverride();
this.ContextMenu.Functions.ContextMenu.OnOpenGameObjectContextMenu -= this.OnOpenGameObjectContextMenu;
this.Framework.Update -= this.FrameworkUpdate;
this._localPlayerLock.Dispose();
this.Ipc.Dispose();
this.GameFunctions.Dispose();
this.PluginUi.Dispose();
this.Commands.Dispose();
this.Client.Dispose();
this.ContextMenu.Dispose();
}
private void FrameworkUpdate(Framework framework) {
if (this.ClientState.LocalPlayer is { } player) {
this.LocalPlayer = player;
} else if (!this.ClientState.IsLoggedIn) {
// only set to null if not logged in
this.LocalPlayer = null;
}
}
private void OnOpenGameObjectContextMenu(GameObjectContextMenuOpenArgs args) {
if (args.ObjectId == 0xE0000000) {
return;
}
var obj = this.ObjectTable.SearchById(args.ObjectId);
if (obj is not PlayerCharacter chara) {
return;
}
args.AddCustomItem(new GameObjectContextMenuItem("Invite to ExtraChat Linkshell", _ => {
var name = chara.Name.TextValue;
this.PluginUi.InviteInfo = (name, (ushort) chara.HomeWorld.Id);
}));
}
internal void SaveConfig() {
this.Interface.SavePluginConfig(this.Config);
}
internal void ShowInfo(string message) {
if (this.Config.UseNativeToasts) {
this.ToastGui.ShowNormal(message);
} else {
this.Interface.UiBuilder.AddNotification(message, this.Name, NotificationType.Info);
}
}
internal void ShowError(string message) {
if (this.Config.UseNativeToasts) {
this.ToastGui.ShowError(message);
} else {
this.Interface.UiBuilder.AddNotification(message, this.Name, NotificationType.Error);
}
}
}

View File

@ -0,0 +1,13 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class AuthenticateRequest {
[Key(0)]
public string Key;
[Key(1)]
public byte[] PublicKey;
}

View File

@ -0,0 +1,10 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class AuthenticateResponse {
[Key(0)]
public string? Error;
}

View File

@ -0,0 +1,24 @@
using System.Text;
using ExtraChat.Formatters;
using ExtraChat.Util;
using MessagePack;
namespace ExtraChat.Protocol.Channels;
[Serializable]
[MessagePackObject]
public class Channel {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Id;
[Key(1)]
public byte[] Name;
[Key(2)]
public List<Member> Members;
internal string DecryptName(byte[] key) {
return Encoding.UTF8.GetString(SecretBox.Decrypt(key, this.Name));
}
}

View File

@ -0,0 +1,19 @@
using MessagePack;
namespace ExtraChat.Protocol.Channels;
[Serializable]
[MessagePackObject]
public class Member {
[Key(0)]
public string Name;
[Key(1)]
public ushort World;
[Key(2)]
public Rank Rank;
[Key(3)]
public bool Online;
}

View File

@ -0,0 +1,20 @@
namespace ExtraChat.Protocol.Channels;
[Serializable]
public enum Rank : byte {
Invited = 0,
Member = 1,
Moderator = 2,
Admin = 3,
}
internal static class RankExt {
internal static string Symbol(this Rank rank) => rank switch {
// invited: a question mark with a circle around it
Rank.Invited => "? ",
Rank.Member => "",
Rank.Moderator => "☆ ",
Rank.Admin => "★ ",
_ => "",
};
}

View File

@ -0,0 +1,18 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol.Channels;
[Serializable]
[MessagePackObject]
public class SimpleChannel {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Id;
[Key(1)]
public byte[] Name;
[Key(2)]
public Rank Rank;
}

View File

@ -0,0 +1,10 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class CreateRequest {
[Key(0)]
public byte[] Name;
}

View File

@ -0,0 +1,11 @@
using ExtraChat.Protocol.Channels;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class CreateResponse {
[Key(0)]
public Channel Channel;
}

View File

@ -0,0 +1,12 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class DisbandRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
}

View File

@ -0,0 +1,12 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class DisbandResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
}

View File

@ -0,0 +1,15 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class ErrorResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidNullableFormatter))]
public Guid? Channel;
[Key(1)]
public string Error;
}

View File

@ -0,0 +1,21 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class InviteRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
[Key(3)]
public byte[] EncryptedSecret;
}

View File

@ -0,0 +1,18 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class InviteResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
}

View File

@ -0,0 +1,23 @@
using ExtraChat.Protocol.Channels;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class InvitedResponse {
[Key(0)]
public Channel Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
[Key(3)]
public byte[] PublicKey;
[Key(4)]
public byte[] EncryptedSecret;
}

View File

@ -0,0 +1,12 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class JoinRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
}

View File

@ -0,0 +1,11 @@
using ExtraChat.Protocol.Channels;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class JoinResponse {
[Key(0)]
public Channel Channel;
}

View File

@ -0,0 +1,18 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class KickRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
}

View File

@ -0,0 +1,18 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class KickResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
}

View File

@ -0,0 +1,12 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class LeaveRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
}

View File

@ -0,0 +1,15 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class LeaveResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string? Error;
}

View File

@ -0,0 +1,17 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
[MessagePackFormatter(typeof(ListRequestFormatter))]
public abstract record ListRequest {
public record All : ListRequest;
public record Channels : ListRequest;
public record Members(Guid ChannelId) : ListRequest;
public record Invites : ListRequest;
}

View File

@ -0,0 +1,22 @@
using ExtraChat.Formatters;
using ExtraChat.Protocol.Channels;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
[MessagePackFormatter(typeof(ListResponseFormatter))]
public abstract record ListResponse {
[MessagePackObject]
public record All(Channel[] AllChannels, Channel[] AllInvites) : ListResponse;
[MessagePackObject]
public record Channels(SimpleChannel[] SimpleChannels) : ListResponse;
[MessagePackObject]
public record Members(Guid ChannelId, Member[] AllMembers) : ListResponse;
[MessagePackObject]
public record Invites(SimpleChannel[] AllInvites) : ListResponse;
}

View File

@ -0,0 +1,31 @@
using ExtraChat.Formatters;
using ExtraChat.Protocol.Channels;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
[MessagePackFormatter(typeof(MemberChangeKindFormatter))]
public abstract record MemberChangeKind {
[MessagePackObject]
public record Invite(string Inviter, ushort InviterWorld) : MemberChangeKind;
[MessagePackObject]
public record InviteDecline : MemberChangeKind;
[MessagePackObject]
public record InviteCancel(string Canceler, ushort CancelerWorld) : MemberChangeKind;
[MessagePackObject]
public record Join : MemberChangeKind;
[MessagePackObject]
public record Leave : MemberChangeKind;
[MessagePackObject]
public record Promote(Rank Rank) : MemberChangeKind;
[MessagePackObject]
public record Kick(string Kicker, ushort KickerWorld) : MemberChangeKind;
}

View File

@ -0,0 +1,21 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class MemberChangeResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
[Key(3)]
public MemberChangeKind Kind;
}

View File

@ -0,0 +1,15 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class MessageRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public byte[] Message;
}

View File

@ -0,0 +1,21 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class MessageResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Sender;
[Key(2)]
public ushort World;
[Key(3)]
public byte[] Message;
}

View File

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

View File

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

View File

@ -0,0 +1,22 @@
using ExtraChat.Formatters;
using ExtraChat.Protocol.Channels;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class PromoteRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
[Key(3)]
public Rank Rank;
}

View File

@ -0,0 +1,22 @@
using ExtraChat.Formatters;
using ExtraChat.Protocol.Channels;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class PromoteResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public string Name;
[Key(2)]
public ushort World;
[Key(3)]
public Rank Rank;
}

View File

@ -0,0 +1,13 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class PublicKeyRequest {
[Key(0)]
public string Name;
[Key(1)]
public ushort World;
}

View File

@ -0,0 +1,16 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class PublicKeyResponse {
[Key(0)]
public string Name;
[Key(1)]
public ushort World;
[Key(2)]
public byte[]? PublicKey;
}

View File

@ -0,0 +1,16 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class RegisterRequest {
[Key(0)]
public string Name;
[Key(1)]
public ushort World;
[Key(2)]
public bool ChallengeCompleted;
}

View File

@ -0,0 +1,18 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
[MessagePackFormatter(typeof(RegisterResponseFormatter))]
public abstract record RegisterResponse {
[MessagePackObject]
public record Challenge(string Text) : RegisterResponse;
[MessagePackObject]
public record Failure : RegisterResponse;
[MessagePackObject]
public record Success(string Key) : RegisterResponse;
}

View File

@ -0,0 +1,13 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class RequestContainer {
[Key(0)]
public uint Number;
[Key(1)]
public RequestKind Kind;
}

View File

@ -0,0 +1,57 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
[MessagePackFormatter(typeof(RequestKindFormatter))]
public abstract record RequestKind {
[MessagePackObject]
public record Ping(PingRequest Request) : RequestKind;
[MessagePackObject]
public record Register(RegisterRequest Request) : RequestKind;
[MessagePackObject]
public record Authenticate(AuthenticateRequest Request) : RequestKind;
[MessagePackObject]
public record Message(MessageRequest Request) : RequestKind;
[MessagePackObject]
public record Create(CreateRequest Request) : RequestKind;
[MessagePackObject]
public record PublicKey(PublicKeyRequest Request) : RequestKind;
[MessagePackObject]
public record Invite(InviteRequest Request) : RequestKind;
[MessagePackObject]
public record Join(JoinRequest Request) : RequestKind;
[MessagePackObject]
public record List(ListRequest Request) : RequestKind;
[MessagePackObject]
public record Leave(LeaveRequest Request) : RequestKind;
[MessagePackObject]
public record Kick(KickRequest Request) : RequestKind;
[MessagePackObject]
public record Disband(DisbandRequest Request) : RequestKind;
[MessagePackObject]
public record Promote(PromoteRequest Request) : RequestKind;
[MessagePackObject]
public record Update(UpdateRequest Request) : RequestKind;
[MessagePackObject]
public record Secrets(SecretsRequest Request) : RequestKind;
[MessagePackObject]
public record SendSecrets(SendSecretsRequest Request) : RequestKind;
}

View File

@ -0,0 +1,13 @@
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class ResponseContainer {
[Key(0)]
public uint Number;
[Key(1)]
public ResponseKind Kind;
}

View File

@ -0,0 +1,69 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
[MessagePackFormatter(typeof(ResponseKindFormatter))]
public abstract record ResponseKind {
[MessagePackObject]
public record Ping(PingResponse Response) : ResponseKind;
[MessagePackObject]
public record Error(ErrorResponse Response) : ResponseKind;
[MessagePackObject]
public record Register(RegisterResponse Response) : ResponseKind;
[MessagePackObject]
public record Authenticate(AuthenticateResponse Response) : ResponseKind;
[MessagePackObject]
public record Message(MessageResponse Response) : ResponseKind;
[MessagePackObject]
public record Create(CreateResponse Response) : ResponseKind;
[MessagePackObject]
public record PublicKey(PublicKeyResponse Response) : ResponseKind;
[MessagePackObject]
public record Invite(InviteResponse Response) : ResponseKind;
[MessagePackObject]
public record Invited(InvitedResponse Response) : ResponseKind;
[MessagePackObject]
public record Join(JoinResponse Response) : ResponseKind;
[MessagePackObject]
public record List(ListResponse Response) : ResponseKind;
[MessagePackObject]
public record Leave(LeaveResponse Response) : ResponseKind;
[MessagePackObject]
public record Kick(KickResponse Response) : ResponseKind;
[MessagePackObject]
public record Disband(DisbandResponse Response) : ResponseKind;
[MessagePackObject]
public record Promote(PromoteResponse Response) : ResponseKind;
[MessagePackObject]
public record MemberChange(MemberChangeResponse Response) : ResponseKind;
[MessagePackObject]
public record Update(UpdateResponse Response) : ResponseKind;
[MessagePackObject]
public record Updated(UpdatedResponse Response) : ResponseKind;
[MessagePackObject]
public record Secrets(SecretsResponse Response) : ResponseKind;
[MessagePackObject]
public record SendSecrets(SendSecretsResponse Response) : ResponseKind;
}

View File

@ -0,0 +1,12 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class SecretsRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
}

View File

@ -0,0 +1,18 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class SecretsResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public byte[] PublicKey;
[Key(2)]
public byte[] EncryptedSharedSecret;
}

View File

@ -0,0 +1,15 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class SendSecretsRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid RequestId;
[Key(1)]
public byte[]? EncryptedSharedSecret;
}

View File

@ -0,0 +1,19 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class SendSecretsResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid RequestId;
[Key(2)]
public byte[] PublicKey;
}

View File

@ -0,0 +1,12 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
[MessagePackFormatter(typeof(UpdateKindFormatter))]
public abstract record UpdateKind {
[MessagePackObject]
public record Name(byte[] NewName) : UpdateKind;
}

View File

@ -0,0 +1,15 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class UpdateRequest {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public UpdateKind Kind;
}

View File

@ -0,0 +1,12 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class UpdateResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
}

View File

@ -0,0 +1,15 @@
using ExtraChat.Formatters;
using MessagePack;
namespace ExtraChat.Protocol;
[Serializable]
[MessagePackObject]
public class UpdatedResponse {
[Key(0)]
[MessagePackFormatter(typeof(BinaryUuidFormatter))]
public Guid Channel;
[Key(1)]
public UpdateKind Kind;
}

View File

@ -0,0 +1,738 @@
using System.Diagnostics;
using System.Numerics;
using System.Text;
using System.Threading.Channels;
using Dalamud.Interface;
using Dalamud.Plugin;
using ExtraChat.Protocol;
using ExtraChat.Protocol.Channels;
using ExtraChat.Util;
using ImGuiNET;
using Lumina.Excel.GeneratedSheets;
using Channel = System.Threading.Channels.Channel;
namespace ExtraChat.Ui;
internal class PluginUi : IDisposable {
internal const string CrossWorld = "\ue05d";
private Plugin Plugin { 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._uiColours = this.Plugin.DataManager.GetExcelSheet<UIColor>()!
.Where(row => row.UIForeground is not (0 or 0x000000FF))
.Select(row => (row.RowId, row.UIForeground, ColourUtil.Step(row.UIForeground)))
.GroupBy(row => row.UIForeground)
.Select(grouping => grouping.First())
.OrderBy(row => row.Item3.Item1)
.ThenBy(row => row.Item3.Item2)
.ThenBy(row => row.Item3.Item3)
.Select(row => (row.RowId, ImGui.ColorConvertU32ToFloat4(ColourUtil.RgbaToAbgr(row.Item2))))
.ToList();
this.Plugin.Interface.UiBuilder.Draw += this.Draw;
this.Plugin.Interface.UiBuilder.OpenConfigUi += this.OpenConfigUi;
if (this.Plugin.Interface.Reason == PluginLoadReason.Installer && this.Plugin.ConfigInfo.Key == null) {
this.Visible = true;
}
}
public void Dispose() {
this.Plugin.Interface.UiBuilder.OpenConfigUi -= this.OpenConfigUi;
this.Plugin.Interface.UiBuilder.Draw -= this.Draw;
}
private void OpenConfigUi() {
this.Visible ^= true;
}
internal (string, ushort)? InviteInfo;
private volatile bool _busy;
private string? _challenge;
private string _createName = string.Empty;
private Guid? _inviteId;
private readonly Channel<string?> _challengeChannel = Channel.CreateUnbounded<string?>();
private void Draw() {
if (this._challengeChannel.Reader.TryRead(out var challenge)) {
this._challenge = challenge;
}
this.DrawConfigWindow();
this.DrawInviteWindow();
}
private void DrawConfigWindow() {
if (!this.Visible) {
return;
}
ImGui.SetNextWindowSize(new Vector2(500, 325) * ImGuiHelpers.GlobalScale, ImGuiCond.FirstUseEver);
if (!ImGui.Begin(this.Plugin.Name, ref this.Visible)) {
ImGui.End();
return;
}
if (!this.Plugin.ClientState.IsLoggedIn) {
ImGui.TextUnformatted("Please log in to a character.");
ImGui.End();
return;
}
if (ImGui.BeginTabBar("tabs")) {
if (ImGui.BeginTabItem("Linkshells")) {
var status = this.Plugin.Client.Status;
ImGui.TextUnformatted($"Status: {status}");
switch (status) {
case Client.State.Connected:
this.DrawList();
break;
case Client.State.NotAuthenticated:
case Client.State.RetrievingChallenge:
case Client.State.WaitingForVerification:
case Client.State.Verifying:
this.DrawRegistrationPanel();
break;
}
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Settings")) {
this.DrawSettings();
ImGui.EndTabItem();
}
ImGui.EndTabBar();
}
ImGui.End();
}
private void DrawSettings() {
var anyChanged = false;
if (ImGui.BeginTabBar("settings-tabs")) {
if (ImGui.BeginTabItem("General")) {
this.DrawSettingsGeneral(ref anyChanged);
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Linkshells")) {
this.DrawSettingsLinkshells(ref anyChanged);
ImGui.EndTabItem();
}
ImGui.EndTabBar();
}
if (anyChanged) {
this.Plugin.SaveConfig();
this.Plugin.Ipc.BroadcastChannelCommandColours();
}
}
private void DrawSettingsGeneral(ref bool anyChanged) {
anyChanged |= ImGui.Checkbox("Use native toasts", ref this.Plugin.Config.UseNativeToasts);
// ImGui.Spacing();
//
// ImGui.TextUnformatted("Default channel");
// ImGui.SetNextItemWidth(-1);
// if (ImGui.BeginCombo("##default-channel", $"{this.Plugin.Config.DefaultChannel}")) {
// foreach (var channel in Enum.GetValues<XivChatType>()) {
// if (ImGui.Selectable($"{channel}", this.Plugin.Config.DefaultChannel == channel)) {
// this.Plugin.Config.DefaultChannel = channel;
// anyChanged = true;
// }
// }
//
// ImGui.EndCombo();
// }
}
private void DrawSettingsLinkshells(ref bool anyChanged) {
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);
foreach (var id in orderedChannels) {
var name = this.Plugin.ConfigInfo.GetName(id);
if (ImGui.CollapsingHeader($"{name}###{id}-settings")) {
ImGui.PushID($"{id}-settings");
ImGui.TextUnformatted("Number");
channelOrder.TryGetValue(id, out var refOrder);
var old = refOrder;
refOrder += 1;
ImGui.SetNextItemWidth(-1);
if (ImGui.InputInt("##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;
anyChanged = true;
this.Plugin.Commands.ReregisterAll();
}
ImGui.Spacing();
if (ImGuiUtil.IconButton(FontAwesomeIcon.Undo, "colour-reset", "Reset")) {
anyChanged = true;
this.Plugin.ConfigInfo.ChannelColors.Remove(id);
}
ImGui.SameLine();
var colourKey = this.Plugin.ConfigInfo.GetUiColour(id);
var colour = this.Plugin.DataManager.GetExcelSheet<UIColor>()!.GetRow(colourKey)?.UIForeground ?? 0xff5ad0ff;
var vec = ImGui.ColorConvertU32ToFloat4(ColourUtil.RgbaToAbgr(colour));
const string colourPickerId = "linkshell-colour-picker";
if (ImGui.ColorButton("Linkshell colour", vec, ImGuiColorEditFlags.NoTooltip)) {
ImGui.OpenPopup(colourPickerId);
}
ImGui.SameLine();
ImGui.TextUnformatted("Linkshell colour");
if (ImGui.BeginPopup(colourPickerId)) {
var i = 0;
foreach (var (uiColour, fg) in this._uiColours) {
if (ImGui.ColorButton($"Colour {uiColour}", fg, ImGuiColorEditFlags.NoTooltip)) {
this.Plugin.ConfigInfo.ChannelColors[id] = (ushort) uiColour;
anyChanged = true;
ImGui.CloseCurrentPopup();
}
if (i >= 11) {
i = 0;
} else {
ImGui.SameLine();
i += 1;
}
}
ImGui.EndPopup();
}
ImGui.Spacing();
var hint = $"ECLS{refOrder}";
if (!this.Plugin.ConfigInfo.ChannelMarkers.TryGetValue(id, out var marker)) {
marker = string.Empty;
}
ImGui.TextUnformatted("Chat marker");
ImGui.SetNextItemWidth(-1);
if (ImGui.InputTextWithHint("##marker", hint, ref marker, 16)) {
anyChanged = true;
if (string.IsNullOrWhiteSpace(marker)) {
this.Plugin.ConfigInfo.ChannelMarkers.Remove(id);
} else {
this.Plugin.ConfigInfo.ChannelMarkers[id] = marker;
}
}
// ImGui.Spacing();
//
// ImGui.TextUnformatted("Output channel");
// ImGui.SetNextItemWidth(-1);
//
// var contained = this.Plugin.ConfigInfo.ChannelChannels.TryGetValue(id, out var output);
// var preview = contained ? $"{output}" : "Default";
//
// if (ImGui.BeginCombo("##output-channel", preview)) {
// if (ImGui.Selectable("Default", !contained)) {
// this.Plugin.ConfigInfo.ChannelChannels.Remove(id);
// anyChanged = true;
// }
//
// foreach (var channel in Enum.GetValues<XivChatType>()) {
// if (ImGui.Selectable($"{channel}", contained && output == channel)) {
// this.Plugin.ConfigInfo.ChannelChannels[id] = channel;
// anyChanged = true;
// }
// }
//
// ImGui.EndCombo();
// }
ImGui.PopID();
}
}
}
private void DrawInviteWindow() {
if (this.InviteInfo == null) {
return;
}
var (name, world) = this.InviteInfo.Value;
var open = true;
if (!ImGui.Begin($"Invite: {name}###ec-linkshell-invite", ref open, ImGuiWindowFlags.AlwaysAutoResize)) {
if (!open) {
this.InviteInfo = null;
}
ImGui.End();
return;
}
if (!open) {
this.InviteInfo = null;
}
if (ImGui.IsWindowAppearing()) {
ImGui.SetWindowPos(ImGui.GetMousePos());
}
var preview = this._inviteId == null ? "Choose a linkshell" : "???";
if (this._inviteId != null && this.Plugin.ConfigInfo.Channels.TryGetValue(this._inviteId.Value, out var selectedInfo)) {
preview = selectedInfo.Name;
}
if (ImGui.BeginCombo("##ec-linkshell-invite-linkshell", preview)) {
foreach (var (id, _) in this.Plugin.Client.Channels) {
if (!this.Plugin.Client.ChannelRanks.TryGetValue(id, out var rank) || rank < Rank.Moderator) {
continue;
}
if (!this.Plugin.ConfigInfo.Channels.TryGetValue(id, out var info)) {
continue;
}
if (ImGui.Selectable($"{info.Name}##{id}", id == this._inviteId)) {
this._inviteId = id;
}
}
ImGui.EndCombo();
}
if (ImGui.Button("Invite") && this._inviteId != null) {
var id = this._inviteId.Value;
this._inviteId = null;
Task.Run(async () => await this.Plugin.Client.InviteToast(name, world, id));
this.InviteInfo = null;
}
ImGui.End();
}
private void DrawRegistrationPanel() {
if (this.Plugin.LocalPlayer is not { } player) {
return;
}
var state = this.Plugin.Client.Status;
if (state == Client.State.NotAuthenticated) {
if (this.Plugin.ConfigInfo.Key != null) {
ImGui.TextUnformatted("Please wait...");
} else {
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);
}
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("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();
}
}
if (state == Client.State.RetrievingChallenge) {
ImGui.TextUnformatted("Waiting...");
}
if (state == Client.State.WaitingForVerification) {
ImGui.PushTextWrapPos();
if (this._challenge == null) {
ImGui.TextUnformatted("Waiting for verification but no challenge present. This is a bug.");
} else {
ImGui.TextUnformatted("Copy the challenge below and save it in your Lodestone profile. After saving, click the button below to verify. After successfully verifying, you can delete the challenge from your profile if desired.");
ImGui.SetNextItemWidth(-1);
ImGui.InputText("##challenge", ref this._challenge, (uint) this._challenge.Length, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.ReadOnly);
if (ImGui.Button("Copy")) {
ImGui.SetClipboardText(this._challenge);
}
ImGui.SameLine();
if (ImGui.Button("Open profile")) {
Process.Start(new ProcessStartInfo {
FileName = "https://na.finalfantasyxiv.com/lodestone/my/setting/profile/",
UseShellExecute = true,
});
}
ImGui.SameLine();
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);
}
}
ImGui.PopTextWrapPos();
}
}
private Guid _selectedChannel = Guid.Empty;
private string _inviteName = string.Empty;
private ushort _inviteWorld;
private string _rename = string.Empty;
private void DrawList() {
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());
}
ImGui.SameLine(addOffset);
if (ImGui.Button(FontAwesomeIcon.Plus.ToIconString())) {
ImGui.OpenPopup("create-channel-popup");
}
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();
}
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 (ImGui.BeginTable("ecls-list", 2, ImGuiTableFlags.Resizable | ImGuiTableFlags.SizingFixedFit)) {
ImGui.TableSetupColumn("##channels", ImGuiTableColumnFlags.None);
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)) {
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 (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();
}
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;
}
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 (ImGui.BeginPopupContextItem($"{this._selectedChannel}-{member.Name}@{member.World}-context")) {
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));
}
}
}
ImGui.EndPopup();
}
}
}
if (ImGui.BeginChild("channel-info", childSize)) {
DrawInfo();
ImGui.EndChild();
}
}
ImGui.EndTable();
}
}
}

View File

@ -0,0 +1,118 @@
using System.Numerics;
namespace ExtraChat.Util;
internal static class ColourUtil {
internal static uint RgbaToAbgr(uint rgba) {
return (rgba >> 24) // red
| ((rgba & 0x0000ff00) << 8) // blue
| ((rgba & 0x00ff0000) >> 8) // green
| ((rgba & 0x000000ff) << 24); // alpha
}
internal static (double, double, double, double) ExplodeRgba(uint rgba) {
// separate RGBA values
var r = (byte) ((rgba >> 24) & 0xff);
var g = (byte) ((rgba >> 16) & 0xff);
var b = (byte) ((rgba >> 8) & 0xff);
var a = (byte) (rgba & 0xff);
// convert RGBA to floats
var rf = r / 255d;
var gf = g / 255d;
var bf = b / 255d;
var af = a / 255d;
return (rf, gf, bf, af);
}
internal static Vector4 RgbaToHsl(uint rgba) {
var (rf, gf, bf, af) = ExplodeRgba(rgba);
// determine hue
var max = Math.Max(rf, Math.Max(gf, bf));
var min = Math.Min(rf, Math.Min(gf, bf));
var chroma = max - min;
var hPrime = 0d;
if (chroma == 0) {
hPrime = 0d;
} else if (Math.Abs(rf - max) < 0.0001) {
hPrime = ((gf - bf) / chroma) % 6;
} else if (Math.Abs(gf - max) < 0.0001) {
hPrime = 2 + (bf - rf) / chroma;
} else if (Math.Abs(bf - max) < 0.0001) {
hPrime = 4 + (rf - gf) / chroma;
}
var h = hPrime * 60f;
// determine lightness
var l = (min + max) / 2f;
// determine saturation
double s;
if (l is 0 or 1) {
s = 0d;
} else {
s = chroma / (1 - Math.Abs(2 * l - 1));
}
return new Vector4((float) h, (float) s, (float) l, (float) af);
}
internal static Vector4 RgbaToHsv(uint rgba) {
var (rf, gf, bf, af) = ExplodeRgba(rgba);
// determine hue
var max = Math.Max(rf, Math.Max(gf, bf));
var min = Math.Min(rf, Math.Min(gf, bf));
var chroma = max - min;
var hPrime = 0d;
if (chroma == 0) {
hPrime = 0d;
} else if (Math.Abs(rf - max) < 0.0001) {
hPrime = ((gf - bf) / chroma) % 6;
} else if (Math.Abs(gf - max) < 0.0001) {
hPrime = 2 + (bf - rf) / chroma;
} else if (Math.Abs(bf - max) < 0.0001) {
hPrime = 4 + (rf - gf) / chroma;
}
var h = hPrime * 60f;
// determine lightness
var v = max;
// determine saturation
double s;
if (v is 0) {
s = 0d;
} else {
s = chroma / v;
}
return new Vector4((float) h, (float) s, (float) v, (float) af);
}
internal static double Luma(uint rgba) {
var (r, g, b, _) = ExplodeRgba(rgba);
return 0.2627 * r + 0.6780 * g + 0.0593 * b;
}
internal static (int, int, int) Step(uint rgba) {
var (r, g, b, _) = ExplodeRgba(rgba);
var lum = Math.Sqrt(0.241 * r + 0.691 * g + 0.068 * b);
var hsv = RgbaToHsv(rgba);
const int reps = 8;
var h2 = (int) (hsv.X * reps);
var lum2 = (int) (lum * reps);
var v2 = (int) (hsv.Z * reps);
if (h2 % 2 == 1) {
v2 = reps - v2;
lum2 = reps - lum2;
}
return (h2, lum2, v2);
}
}

View File

@ -0,0 +1,72 @@
using System.Text;
using Dalamud.Interface;
using ImGuiNET;
namespace ExtraChat.Util;
internal static class ImGuiUtil {
internal static bool IconButton(FontAwesomeIcon icon, string? id = null, string? tooltip = null) {
var label = icon.ToIconString();
if (id != null) {
label += $"##{id}";
}
ImGui.PushFont(UiBuilder.IconFont);
var ret = ImGui.Button(label);
ImGui.PopFont();
if (tooltip != null && ImGui.IsItemHovered()) {
ImGui.BeginTooltip();
ImGui.TextUnformatted(tooltip);
ImGui.EndTooltip();
}
return ret;
}
internal static bool SelectableConfirm(string label, ConfirmKey keys = ConfirmKey.Ctrl, string? tooltip = null) {
var selectable = ImGui.Selectable(label);
var hovered = ImGui.IsItemHovered();
var confirmHeld = true;
var mods = hovered ? new StringBuilder() : null;
foreach (var key in Enum.GetValues<ConfirmKey>()) {
if (!keys.HasFlag(key)) {
continue;
}
if (hovered) {
if (mods!.Length != 0) {
mods.Append('+');
}
mods.Append(key.ToString());
}
var held = key switch {
ConfirmKey.Ctrl => ImGui.GetIO().KeyCtrl,
ConfirmKey.Alt => ImGui.GetIO().KeyAlt,
ConfirmKey.Shift => ImGui.GetIO().KeyShift,
_ => false,
};
confirmHeld &= held;
}
if (!confirmHeld && hovered) {
ImGui.BeginTooltip();
var explainer = $"Hold {mods} to enable this option.";
var tip = tooltip == null ? explainer : $"{tooltip}\n{explainer}";
ImGui.TextUnformatted(tip);
ImGui.EndTooltip();
}
return selectable && confirmHeld;
}
}
[Flags]
internal enum ConfirmKey {
Ctrl = 1 << 0,
Alt = 1 << 1,
Shift = 1 << 2,
}

View File

@ -0,0 +1,20 @@
using ASodium;
namespace ExtraChat.Util;
internal static class SecretBox {
internal static byte[] Encrypt(byte[] key, byte[] bytes) {
var nonce = SodiumSecretBoxXChaCha20Poly1305.GenerateNonce();
var ciphertext = SodiumSecretBoxXChaCha20Poly1305.Create(bytes, nonce, key);
return nonce.Concat(ciphertext);
}
internal static byte[] Decrypt(byte[] key, byte[] bytes) {
var nonceLength = SodiumSecretBoxXChaCha20Poly1305.GetNonceBytesLength();
var nonce = bytes[..nonceLength];
var ciphertext = bytes[nonceLength..];
return SodiumSecretBoxXChaCha20Poly1305.Open(ciphertext, nonce, key);
}
}

View File

@ -0,0 +1,29 @@
using Dalamud.Data;
using Lumina.Excel.GeneratedSheets;
namespace ExtraChat.Util;
internal static class WorldUtil {
private static readonly Dictionary<ushort, string> WorldNames = new();
internal static void Initialise(DataManager data) {
WorldNames.Clear();
var worlds = data.GetExcelSheet<World>();
if (worlds == null) {
return;
}
foreach (var world in worlds) {
if (!world.IsPublic) {
continue;
}
WorldNames[(ushort) world.RowId] = world.Name.RawString;
}
}
internal static string WorldName(ushort id) {
return WorldNames.TryGetValue(id, out var name) ? name : "???";
}
}

7
server/.gitignore vendored Executable file
View File

@ -0,0 +1,7 @@
/target
/config.toml
/.env
/database.sqlite
/database.sqlite-shm
/database.sqlite-wal
/extrachat.log

2348
server/Cargo.lock generated Executable file

File diff suppressed because it is too large Load Diff

35
server/Cargo.toml Normal file
View File

@ -0,0 +1,35 @@
[package]
name = "extra-chat-server"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
chrono = "0.4"
fern = "0.6"
futures-util = "0.3"
hex = "0.4"
lazy_static = "1"
lodestone-scraper = { git = "https://git.annaclemens.io/ascclemens/lodestone-scraper.git" }
log = "0.4"
parking_lot = "0.12"
prefixed-api-key = { git = "https://git.annaclemens.io/ascclemens/prefixed-api-key.git" }
rand = "0.8"
regex = "1"
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"
uuid = { version = "1", features = ["serde", "v4"] }
[dependencies.tokio]
version = "1"
features = ["rt-multi-thread", "macros", "sync"]

View File

@ -0,0 +1,5 @@
[server]
address = '0.0.0.0:8080'
[database]
path = './database.sqlite'

View File

View File

@ -0,0 +1,45 @@
create table users
(
lodestone_id unsigned bigint not null primary key,
name text not null,
world text not null,
key_short text not null,
key_hash text not null
);
create table verifications
(
lodestone_id unsigned bigint not null primary key,
challenge text not null,
created_at timestamp not null default current_timestamp
);
create table channels
(
id text not null primary key,
name blob not null
);
create table user_channels
(
lodestone_id unsigned bigint not null references users (lodestone_id) on delete cascade,
channel_id text not null references channels (id) on delete cascade,
rank tinyint not null,
primary key (lodestone_id, channel_id)
);
create index user_channels_lodestone_id_idx on user_channels (lodestone_id);
create index user_channels_channel_id_idx on user_channels (channel_id);
create table channel_invites
(
channel_id text not null references channels (id) on delete cascade,
invited unsigned bigint not null references users (lodestone_id) on delete cascade,
inviter unsigned bigint not null references users (lodestone_id) on delete cascade,
primary key (channel_id, invited)
);
create index channel_invites_channel_id_idx on channel_invites (channel_id);
create index channel_invites_channel_id_invited_idx on channel_invites (channel_id, invited);

View File

@ -0,0 +1,3 @@
-- add a column so we can cache login lodestone requests
alter table users
add column last_updated timestamp not null default 0;

View File

@ -0,0 +1,3 @@
create index users_name_world_idx on users (name, world);
create index users_key_short_key_hash_idx on users (key_short, key_hash);
create index channel_invites_invited_idx on channel_invites (invited);

View File

@ -0,0 +1,86 @@
use std::str::FromStr;
use std::sync::Arc;
use anyhow::Context;
use chrono::{Duration, Utc};
use lodestone_scraper::LodestoneScraper;
use log::trace;
use tokio::sync::RwLock;
use crate::{AuthenticateRequest, AuthenticateResponse, ClientState, State, User, util, World, WsStream};
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(());
}
let key = prefixed_api_key::parse(&*req.key)
.context("could not parse key")?;
let hash = util::hash_key(&key);
let user = sqlx::query!(
// language=sqlite
"select * from users where key_short = ? and key_hash = ?",
key.short_token,
hash,
)
.fetch_optional(&state.read().await.db)
.await
.context("could not query database for user")?;
let mut user = match user {
Some(u) => u,
None => {
util::send(conn, number, AuthenticateResponse::error("invalid key")).await?;
return Ok(());
}
};
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"))?;
trace!(" [authenticate] before user write");
let mut c_state = client_state.write().await;
c_state.user = Some(User {
lodestone_id: user.lodestone_id as u64,
name: user.name.clone(),
world,
hash,
});
c_state.pk = req.pk.into_inner();
// release lock asap
drop(c_state);
trace!(" [authenticate] after user write");
trace!(" [authenticate] before state write 1");
state.write().await.clients.insert(user.lodestone_id as u64, Arc::clone(&client_state));
trace!(" [authenticate] before state write 2");
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?;
Ok(())
}

View File

@ -0,0 +1,55 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{ClientState, ErrorResponse, State, WsStream};
use crate::types::protocol::{CreateRequest, CreateResponse};
use crate::types::protocol::channel::{Channel, Rank};
pub async fn create(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: CreateRequest) -> Result<()> {
let id = Uuid::new_v4();
let id_str = id.as_simple().to_string();
sqlx::query!(
// language=sqlite
"insert into channels (id, name) values (?, ?)",
id_str,
req.name,
)
.execute(&state.read().await.db)
.await
.context("could not create channel")?;
let lodestone_id = client_state.read().await.user.as_ref().map(|u| u.lodestone_id as i64).unwrap_or(0);
if lodestone_id == 0 {
// should not be possible
return Ok(());
}
let rank = Rank::Admin.as_u8();
sqlx::query!(
// language=sqlite
"insert into user_channels (lodestone_id, channel_id, rank) values (?, ?, ?)",
lodestone_id,
id_str,
rank,
)
.execute(&state.read().await.db)
.await
.context("could not add user to channel")?;
let channel = match Channel::get(&state, id).await? {
Some(c) => c,
None => {
return crate::util::send(conn, number, ErrorResponse::new(None, "could not get newly-created channel")).await;
}
};
crate::util::send(conn, number, CreateResponse {
channel,
}).await?;
Ok(())
}

View File

@ -0,0 +1,33 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, Rank, State, WsStream};
use crate::types::protocol::{DisbandRequest, DisbandResponse};
use crate::util::send;
pub async fn disband(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: DisbandRequest) -> Result<()> {
match client_state.read().await.get_rank(req.channel, &state).await? {
Some(rank) if rank == Rank::Admin => {}
_ => return send(conn, number, ErrorResponse::new(req.channel, "not in channel/not enough permissions")).await,
}
crate::util::send_to_all(&state, req.channel, 0, DisbandResponse {
channel: req.channel,
}).await?;
let channel_id_str = req.channel.as_simple().to_string();
sqlx::query!(
// language=sqlite
"delete from channels where id = ?",
channel_id_str,
)
.execute(&state.read().await.db)
.await
.context("could not disband channel")?;
send(conn, number, DisbandResponse {
channel: req.channel,
}).await
}

View File

@ -0,0 +1,121 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, ResponseContainer, State, WsStream};
use crate::types::protocol::{InvitedResponse, InviteRequest, InviteResponse, MemberChangeKind, MemberChangeResponse, ResponseKind};
use crate::types::protocol::channel::{Channel, Rank};
pub async fn invite(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: InviteRequest) -> Result<()> {
let user = match &client_state.read().await.user {
Some(u) => u.clone(),
None => return Ok(()),
};
let lodestone_id = user.lodestone_id as i64;
let rank = match client_state.read().await.get_rank(req.channel, &state).await? {
Some(r) => r,
None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, "not in channel")).await,
};
if rank < Rank::Moderator {
return crate::util::send(conn, number, ErrorResponse::new(req.channel, "not enough permissions to invite")).await;
}
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,
};
let target_id_i = target_id as i64;
if target_id_i == lodestone_id {
return crate::util::send(conn, number, ErrorResponse::new(req.channel, "cannot invite self")).await;
}
let channel_id = req.channel.as_simple().to_string();
// check for existing membership
let membership = sqlx::query!(
// language=sqlite
"select count(*) as count from user_channels where channel_id = ? and lodestone_id = ?",
channel_id,
target_id_i,
)
.fetch_one(&state.read().await.db)
.await
.context("could not query database for membership")?;
if membership.count > 0 {
return crate::util::send(conn, number, ErrorResponse::new(req.channel, "already in channel")).await;
}
// check for existing invite
let invite = sqlx::query!(
// language=sqlite
"select count(*) as count from channel_invites where channel_id = ? and invited = ?",
channel_id,
target_id_i,
)
.fetch_one(&state.read().await.db)
.await
.context("could not query database for invite")?;
if invite.count > 0 {
return crate::util::send(conn, number, ErrorResponse::new(req.channel, "already invited")).await;
}
crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse {
channel: req.channel,
name: req.name.clone(),
world: req.world,
kind: MemberChangeKind::Invite {
inviter: user.name,
inviter_world: crate::util::id_from_world(user.world),
},
}).await?;
sqlx::query!(
// language=sqlite
"insert into channel_invites (channel_id, invited, inviter) values (?, ?, ?)",
channel_id,
target_id_i,
lodestone_id,
)
.execute(&state.read().await.db)
.await
.context("could not add invite")?;
// inviter's info
let pk = client_state.read().await.pk.clone();
let (name, world) = match &client_state.read().await.user {
Some(c) => (c.name.clone(), c.world),
None => return Ok(()),
};
// send invite to invitee
match state.read().await.clients.get(&target_id) {
Some(c) => {
let channel = Channel::get(&state, req.channel)
.await
.context("could not get channel")?
.context("no such channel")?;
c.read().await.tx.send(ResponseContainer {
number: 0,
kind: ResponseKind::Invited(InvitedResponse {
channel,
name,
world: crate::util::id_from_world(world),
pk: pk.into(),
encrypted_secret: req.encrypted_secret,
}),
}).await?;
}
None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, "user not online")).await,
}
crate::util::send(conn, number, InviteResponse {
channel: req.channel,
name: req.name,
world: req.world,
}).await
}

View File

@ -0,0 +1,60 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, State, WsStream};
use crate::types::protocol::{JoinRequest, JoinResponse, MemberChangeKind, MemberChangeResponse};
use crate::types::protocol::channel::{Channel, Rank};
use crate::util::send;
pub async fn join(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: JoinRequest) -> Result<()> {
let user = match &client_state.read().await.user {
Some(user) => user.clone(),
None => return Ok(()),
};
let lodestone_id = user.lodestone_id as i64;
let channel_id = req.channel.as_simple().to_string();
let invite = sqlx::query!(
// language=sqlite
"delete from channel_invites where channel_id = ? and invited = ? returning *",
channel_id,
lodestone_id,
)
.fetch_optional(&state.read().await.db)
.await
.context("failed to fetch invite")?;
if invite.is_none() {
return send(conn, number, ErrorResponse::new(req.channel, "you were not invited to that channel")).await;
}
crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse {
channel: req.channel,
name: user.name,
world: crate::util::id_from_world(user.world),
kind: MemberChangeKind::Join,
}).await?;
let rank = Rank::Member.as_u8();
sqlx::query!(
// language=sqlite
"insert into user_channels (lodestone_id, channel_id, rank) values (?, ?, ?)",
lodestone_id,
channel_id,
rank,
)
.execute(&state.read().await.db)
.await
.context("failed to add user to channel")?;
let channel = Channel::get(&state, req.channel)
.await
.context("failed to get channel")?
.context("no such channel")?;
send(conn, number, JoinResponse {
channel,
}).await
}

View File

@ -0,0 +1,98 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, State, WsStream};
use crate::types::protocol::{KickRequest, KickResponse, MemberChangeKind, MemberChangeResponse};
use crate::types::protocol::channel::Rank;
use crate::util::send;
pub async fn kick(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: KickRequest) -> Result<()> {
let user = match &client_state.read().await.user {
Some(user) => user.clone(),
None => return Ok(()),
};
let rank = match client_state.read().await.get_rank(req.channel, &state).await? {
Some(rank) if rank >= Rank::Moderator => rank,
_ => return send(conn, number, ErrorResponse::new(req.channel, "not in channel/not enough permissions")).await,
};
let target_id = match state.read().await.get_id(&state, &req.name, req.world).await {
Some(id) => id,
None => return send(conn, number, ErrorResponse::new(req.channel, "user not found")).await,
};
let target_id_i = target_id as i64;
let channel_id_str = req.channel.as_simple().to_string();
let target_rank: Option<Rank> = sqlx::query!(
// language=sqlite
"select rank from user_channels where channel_id = ? and lodestone_id = ?",
channel_id_str,
target_id_i,
)
.fetch_optional(&state.read().await.db)
.await
.context("could not query database for rank")?
.map(|row| (row.rank as u8).into());
match target_rank {
Some(target) if target >= rank => {
return send(conn, number, ErrorResponse::new(req.channel, "cannot kick someone of equal or higher rank")).await;
}
None if !crate::util::is_invited(&state, req.channel, target_id).await? => {
return send(conn, number, ErrorResponse::new(req.channel, "user not in channel")).await;
}
_ => {}
}
let is_invited = target_rank.is_none();
let kind = if is_invited {
MemberChangeKind::InviteCancel {
canceler: user.name,
canceler_world: crate::util::id_from_world(user.world),
}
} else {
MemberChangeKind::Kick {
kicker: user.name,
kicker_world: crate::util::id_from_world(user.world),
}
};
crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse {
channel: req.channel,
name: req.name.clone(),
world: req.world,
kind,
}).await?;
if is_invited {
sqlx::query!(
// language=sqlite
"delete from channel_invites where channel_id = ? and invited = ?",
channel_id_str,
target_id_i,
)
.execute(&state.read().await.db)
.await
.context("could not delete invite")?;
} else {
sqlx::query!(
// language=sqlite
"delete from user_channels where channel_id = ? and lodestone_id = ?",
channel_id_str,
target_id_i,
)
.execute(&state.read().await.db)
.await
.context("could not kick user")?;
}
send(conn, number, KickResponse {
channel: req.channel,
name: req.name.clone(),
world: req.world,
}).await
}

View File

@ -0,0 +1,109 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, Rank, State, types::protocol::{
LeaveRequest,
LeaveResponse,
}, util::send, WsStream};
use crate::types::protocol::{MemberChangeKind, MemberChangeResponse};
pub async fn leave(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: LeaveRequest) -> Result<()> {
let user = match &client_state.read().await.user {
Some(user) => user.clone(),
None => return Ok(()),
};
let lodestone_id = user.lodestone_id as i64;
let channel_id = req.channel.as_simple().to_string();
let rank = match client_state.read().await.get_rank(req.channel, &state).await? {
Some(rank) => rank,
None => {
let is_invited = sqlx::query!(
// language=sqlite
"select count(*) as count from channel_invites where channel_id = ? and invited = ?",
channel_id,
lodestone_id,
)
.fetch_one(&state.read().await.db)
.await
.context("could not get channel members")?
.count > 0;
if is_invited {
Rank::Invited
} else {
return send(conn, number, ErrorResponse::new(req.channel, "not in that channel")).await;
}
}
};
let is_decline = rank == Rank::Invited;
let users: i32 = sqlx::query!(
// language=sqlite
"select count(*) as count from user_channels where channel_id = ?",
channel_id,
)
.fetch_one(&state.read().await.db)
.await
.context("failed to get user count")?
.count;
// if the leaving user is an admin and there's more than one user,
// the admin must promote someone before they can leave
if users > 1 && rank == Rank::Admin {
return send(conn, number, LeaveResponse::error(req.channel, "you must promote someone to admin before leaving")).await;
}
// if there's only one user and this isn't an invite decline, we can
// handle all the logic just with cascade deletes
if users == 1 && !is_decline {
sqlx::query!(
// language=sqlite
"delete from channels where id = ?",
channel_id,
)
.execute(&state.read().await.db)
.await
.context("failed to delete channel")?;
return send(conn, number, LeaveResponse::success(req.channel)).await;
}
let kind = if is_decline {
sqlx::query!(
// language=sqlite
"delete from channel_invites where channel_id = ? and invited = ?",
channel_id,
lodestone_id,
)
.execute(&state.read().await.db)
.await
.context("failed to remove invite")?;
MemberChangeKind::InviteDecline
} else {
sqlx::query!(
// language=sqlite
"delete from user_channels where lodestone_id = ? and channel_id = ?",
lodestone_id,
channel_id,
)
.execute(&state.read().await.db)
.await
.context("failed to remove user from channel")?;
MemberChangeKind::Leave
};
crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse {
channel: req.channel,
name: user.name,
world: crate::util::id_from_world(user.world),
kind,
}).await?;
send(conn, number, LeaveResponse::success(req.channel)).await
}

159
server/src/handlers/list.rs Normal file
View File

@ -0,0 +1,159 @@
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{ClientState, State, types::protocol::{
channel::{
Channel,
ChannelMember,
Rank,
SimpleChannel,
},
ListRequest,
ListResponse,
}, util::send, World, WsStream};
use crate::util::RawMember;
pub async fn list(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: ListRequest) -> Result<()> {
let lodestone_id = match &client_state.read().await.user {
Some(u) => u.lodestone_id,
None => return Ok(()),
};
let resp = match req {
ListRequest::All => ListResponse::All {
channels: get_full_channels(lodestone_id, &state).await?,
invites: get_full_invites(lodestone_id, &state).await?,
},
ListRequest::Channels => ListResponse::Channels(get_channels(lodestone_id, &state).await?),
ListRequest::Members(id) => ListResponse::Members {
id,
members: get_members(lodestone_id, &state, id).await?,
},
ListRequest::Invites => ListResponse::Invites(get_invites(lodestone_id, &state).await?),
};
send(conn, number, resp).await
}
async fn ids_to_channels(ids: &[&str], state: &RwLock<State>) -> Vec<Channel> {
let mut channels = Vec::with_capacity(ids.len());
for id in ids {
let id = match Uuid::from_str(id) {
Ok(id) => id,
Err(_) => continue,
};
let channel = match Channel::get(state, id).await {
Ok(Some(channel)) => channel,
_ => continue,
};
channels.push(channel);
}
channels
}
async fn get_full_channels(lodestone_id: u64, state: &RwLock<State>) -> Result<Vec<Channel>> {
let lodestone_id_i = lodestone_id as i64;
let channel_ids = sqlx::query!(
// language=sqlite
"select channel_id from user_channels where lodestone_id = ?",
lodestone_id_i,
)
.fetch_all(&state.read().await.db)
.await
.context("failed to fetch channel ids")?;
let ids: Vec<&str> = channel_ids
.iter()
.map(|id| id.channel_id.as_str())
.collect();
Ok(ids_to_channels(&ids, state).await)
}
async fn get_full_invites(lodestone_id: u64, state: &RwLock<State>) -> Result<Vec<Channel>> {
let lodestone_id_i = lodestone_id as i64;
let channel_ids = sqlx::query!(
// language=sqlite
"select channel_id from channel_invites where invited = ?",
lodestone_id_i,
)
.fetch_all(&state.read().await.db)
.await
.context("failed to fetch channel ids")?;
let ids: Vec<&str> = channel_ids
.iter()
.map(|id| id.channel_id.as_str())
.collect();
Ok(ids_to_channels(&ids, state).await)
}
async fn get_channels(lodestone_id: u64, state: &RwLock<State>) -> Result<Vec<SimpleChannel>> {
SimpleChannel::get_all_for_user(state, lodestone_id)
.await
.context("could not get channels for user")
}
async fn get_members(lodestone_id: u64, state: &RwLock<State>, channel_id: Uuid) -> Result<Vec<ChannelMember>> {
let lodestone_id_i = lodestone_id as i64;
let channel_id_str = channel_id.as_simple().to_string();
let users: Vec<RawMember> = sqlx::query_as!(
RawMember,
// language=sqlite
"select users.lodestone_id, users.name, users.world, user_channels.rank from user_channels inner join users on users.lodestone_id = user_channels.lodestone_id where user_channels.channel_id = ?",
channel_id_str,
)
.fetch_all(&state.read().await.db)
.await
.context("failed to get members")?;
let invited: Vec<RawMember> = sqlx::query_as!(
RawMember,
// language=sqlite
"select users.lodestone_id, users.name, users.world, cast(0 as int) as rank from channel_invites inner join users on users.lodestone_id = channel_invites.invited where channel_invites.channel_id = ?",
channel_id_str,
)
.fetch_all(&state.read().await.db)
.await
.context("failed to get invited members")?;
let mut found = false;
let mut members = Vec::with_capacity(users.len());
for user in users.into_iter().chain(invited.into_iter()) {
if user.lodestone_id == lodestone_id_i {
found = true;
}
let world = match World::from_str(&user.world) {
Ok(world) => world,
Err(_) => continue,
};
let online = state.read().await.clients.contains_key(&(user.lodestone_id as u64));
members.push(ChannelMember {
name: user.name,
world: crate::util::id_from_world(world),
rank: Rank::from_u8(user.rank as u8),
online,
});
}
if !found {
anyhow::bail!("user not in channel");
}
Ok(members)
}
async fn get_invites(lodestone_id: u64, state: &RwLock<State>) -> Result<Vec<SimpleChannel>> {
SimpleChannel::get_invites_for_user(state, lodestone_id)
.await
.context("could not get channels for user")
}

View File

@ -0,0 +1,56 @@
use std::sync::Arc;
use std::sync::atomic::Ordering;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, MessageRequest, MessageResponse, ResponseContainer, State, util, WsStream};
use crate::types::protocol::ResponseKind;
use crate::util::send;
pub async fn message(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: MessageRequest) -> Result<()> {
let (lodestone_id, sender, world) = match &client_state.read().await.user {
Some(u) => (u.lodestone_id, u.name.clone(), u.world),
None => return Ok(()),
};
let id = req.channel.as_simple().to_string();
let members = sqlx::query!(
// language=sqlite
"select lodestone_id from user_channels where channel_id = ?",
id,
)
.fetch_all(&state.read().await.db)
.await
.context("could not query database for members")?;
let in_channel = members
.iter()
.any(|m| m.lodestone_id as u64 == lodestone_id);
if !in_channel {
return send(conn, number, ErrorResponse::new(req.channel, "not in channel")).await;
}
state.read().await.messages_sent.fetch_add(1, Ordering::SeqCst);
let resp = ResponseContainer {
number: 0,
kind: ResponseKind::Message(MessageResponse {
channel: req.channel,
sender,
world: util::id_from_world(world),
message: req.message,
}),
};
for member in members {
let client = match state.read().await.clients.get(&(member.lodestone_id as u64)).cloned() {
Some(c) => c,
None => continue,
};
client.read().await.tx.send(resp.clone()).await.ok();
}
Ok(())
}

View File

@ -0,0 +1,38 @@
pub use self::{
authenticate::*,
create::*,
disband::*,
invite::*,
join::*,
kick::*,
leave::*,
list::*,
message::*,
ping::*,
promote::*,
public_key::*,
register::*,
secrets::*,
send_secrets::*,
update::*,
version::*,
};
pub mod authenticate;
pub mod create;
pub mod disband;
pub mod invite;
pub mod join;
pub mod kick;
pub mod leave;
pub mod list;
pub mod message;
pub mod ping;
pub mod promote;
pub mod public_key;
pub mod register;
pub mod secrets;
pub mod send_secrets;
pub mod update;
pub mod version;

View File

@ -0,0 +1,8 @@
use anyhow::Result;
use crate::WsStream;
use crate::types::protocol::PingResponse;
pub async fn ping(conn: &mut WsStream, number: u32) -> Result<()> {
crate::util::send(conn, number, PingResponse {}).await
}

View File

@ -0,0 +1,112 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, State, WsStream};
use crate::types::protocol::{MemberChangeResponse, PromoteRequest, PromoteResponse};
use crate::types::protocol::channel::Rank;
use crate::types::protocol::MemberChangeKind;
use crate::util::send;
pub async fn promote(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: PromoteRequest) -> Result<()> {
let user = match &client_state.read().await.user {
Some(user) => user.clone(),
None => return Ok(()),
};
let lodestone_id = user.lodestone_id;
let lodestone_id_i = lodestone_id as i64;
let rank = match client_state.read().await.get_rank(req.channel, &state).await? {
Some(rank) if rank == Rank::Admin => rank,
_ => return send(conn, number, ErrorResponse::new(req.channel, "not in channel/not enough permissions")).await,
};
if req.rank == Rank::Invited {
return send(conn, number, ErrorResponse::new(req.channel, "cannot change rank to invited")).await;
}
let target_id = match state.read().await.get_id(&state, &req.name, req.world).await {
Some(id) => id,
None => return send(conn, number, ErrorResponse::new(req.channel, "user not found")).await,
};
let target_id_i = target_id as i64;
if target_id == lodestone_id {
return send(conn, number, ErrorResponse::new(req.channel, "cannot change own rank")).await;
}
let channel_id_str = req.channel.as_simple().to_string();
let target_rank = sqlx::query!(
// language=sqlite
"select rank from user_channels where channel_id = ? and lodestone_id = ?",
channel_id_str,
target_id_i,
)
.fetch_optional(&state.read().await.db)
.await
.context("could not query database for rank")?;
match target_rank {
Some(target) if target.rank >= rank.as_u8() as i64 => {
return send(conn, number, ErrorResponse::new(req.channel, "cannot change rank of someone of equal or higher rank")).await;
}
None => return send(conn, number, ErrorResponse::new(req.channel, "user not in channel")).await,
_ => {}
}
let swap = req.rank == Rank::Admin;
// change the rank
let new_rank = req.rank.as_u8() as i64;
sqlx::query!(
// language=sqlite
"update user_channels set rank = ? where channel_id = ? and lodestone_id = ?",
new_rank,
channel_id_str,
target_id_i,
)
.execute(&state.read().await.db)
.await
.context("could not update user rank")?;
if swap {
// lower own rank
let new_rank = Rank::Moderator.as_u8() as i64;
sqlx::query!(
// language=sqlite
"update user_channels set rank = ? where channel_id = ? and lodestone_id = ?",
new_rank,
channel_id_str,
lodestone_id_i,
)
.execute(&state.read().await.db)
.await
.context("could not update user rank")?;
crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse {
channel: req.channel,
name: user.name,
world: crate::util::id_from_world(user.world),
kind: MemberChangeKind::Promote {
rank: Rank::Moderator,
},
}).await?;
}
crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse {
channel: req.channel,
name: req.name.clone(),
world: req.world,
kind: MemberChangeKind::Promote {
rank: req.rank,
},
}).await?;
send(conn, number, PromoteResponse {
channel: req.channel,
name: req.name,
world: req.world,
rank: req.rank,
}).await
}

View File

@ -0,0 +1,34 @@
use std::sync::Arc;
use anyhow::Result;
use tokio::sync::RwLock;
use crate::{State, WsStream};
use crate::types::protocol::{PublicKeyRequest, PublicKeyResponse};
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(());
}
};
let pk = match state.read().await.clients.get(&id) {
Some(client) => Some(client.read().await.pk.clone()),
None => None,
};
crate::util::send(conn, number, PublicKeyResponse {
name: req.name,
world: req.world,
pk: pk.map(Redacted),
}).await?;
Ok(())
}

View File

@ -0,0 +1,135 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use lodestone_scraper::LodestoneScraper;
use rand::RngCore;
use tokio::sync::RwLock;
use crate::{ClientState, RegisterRequest, RegisterResponse, State, util::{hash_key, send, world_from_id}, WsStream};
pub async fn register(state: Arc<RwLock<State>>, _client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: RegisterRequest) -> Result<()> {
let scraper = LodestoneScraper::default();
let world = world_from_id(req.world)
.context("invalid world id")?;
// look up character
let character = scraper.character_search()
.name(&req.name)
.world(world)
.send()
.await?
.results
.into_iter()
.find(|c| c.name == req.name && Some(c.world) == world_from_id(req.world))
.context("could not find character")?;
let lodestone_id = character.id as i64;
// get challenge
let challenge: Option<_> = sqlx::query!(
// language=sqlite
"select challenge, created_at from verifications where lodestone_id = ?",
lodestone_id,
)
.fetch_optional(&state.read().await.db)
.await
.context("could not query database for verification")?;
if !req.challenge_completed || challenge.is_none() {
let generate = match &challenge {
Some(r) if Utc::now().signed_duration_since(DateTime::<Utc>::from_utc(r.created_at, Utc)) > Duration::minutes(5) => {
// set up a challenge if one hasn't been set up in the last five minutes
true
}
Some(_) => {
// challenge already exists, send back existing one
false
}
None => true,
};
let challenge = match &challenge {
None | Some(_) if generate => {
let mut rand_bytes = [0; 32];
rand::thread_rng().fill_bytes(&mut rand_bytes);
let challenge = hex::encode(&rand_bytes);
sqlx::query!(
// language=sqlite
"delete from verifications where lodestone_id = ?",
lodestone_id,
)
.execute(&state.read().await.db)
.await?;
sqlx::query!(
// language=sqlite
"insert into verifications (lodestone_id, challenge) values (?, ?)",
lodestone_id,
challenge,
)
.execute(&state.read().await.db)
.await?;
challenge
}
Some(r) => r.challenge.clone(),
None => unreachable!(),
};
send(conn, number, RegisterResponse::Challenge {
challenge,
}).await?;
return Ok(());
}
// verify challenge
let challenge = match challenge {
Some(c) => c,
// should not be possible
None => return Ok(()),
};
let chara_info = scraper.character(character.id)
.await
.context("could not get character info")?;
let verified = chara_info.profile_text.contains(&challenge.challenge);
if !verified {
send(conn, number, RegisterResponse::Failure).await?;
return Ok(());
}
sqlx::query!(
// language=sqlite
"delete from verifications where lodestone_id = ?",
lodestone_id,
)
.execute(&state.read().await.db)
.await
.context("could not remove verification")?;
let key = prefixed_api_key::generate("extrachat", None);
let hash = hash_key(&key);
let world_name = character.world.as_str();
sqlx::query!(
// language=sqlite
"insert or replace into users (lodestone_id, name, world, key_short, key_hash, last_updated) values (?, ?, ?, ?, ?, current_timestamp)",
lodestone_id,
character.name,
world_name,
key.short_token,
hash,
)
.execute(&state.read().await.db)
.await
.context("could not insert user")?;
send(conn, number, RegisterResponse::Success {
key: key.to_string().into(),
}).await?;
Ok(())
}

View File

@ -0,0 +1,84 @@
use std::sync::Arc;
use anyhow::Result;
use rand::seq::SliceRandom;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{ClientState, ErrorResponse, ResponseContainer, State, WsStream};
use crate::types::protocol::{ResponseKind, SecretsRequest, SendSecretsResponse};
use crate::util::send;
#[derive(Clone)]
pub struct SecretsRequestInfo {
pub lodestone_id: u64,
pub channel_id: Uuid,
pub number: u32,
}
pub async fn secrets(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: SecretsRequest) -> Result<()> {
if client_state.read().await.get_rank_invite(req.channel, &state).await?.is_none() {
return send(conn, number, ErrorResponse::new(req.channel, "not in that channel")).await;
}
let lodestone_id = match client_state.read().await.lodestone_id() {
Some(lodestone_id) => lodestone_id,
None => return Ok(()),
};
let all_members = crate::util::get_raw_members(&state, req.channel).await?
.into_iter()
.chain(crate::util::get_raw_invited_members(&state, req.channel).await?.into_iter());
let mut members = Vec::new();
for member in all_members {
let id = member.lodestone_id as u64;
if id != lodestone_id && state.read().await.clients.contains_key(&id) {
members.push(member);
}
}
if members.is_empty() {
return send(conn, number, ErrorResponse::new(req.channel, "no other online members")).await;
}
// because I am lazy
// ask 10% of the online members for their secrets
// take the first one
let mut amount = (members.len() as f32 / 10.0).round() as usize;
if amount == 0 {
amount = 1;
}
let members: Vec<_> = members.choose_multiple(&mut rand::thread_rng(), amount).collect();
if members.is_empty() {
return send(conn, number, ErrorResponse::new(req.channel, "no online members found")).await;
}
let request_id = Uuid::new_v4();
state.write().await.secrets_requests.insert(request_id, SecretsRequestInfo {
lodestone_id,
channel_id: req.channel,
number,
});
let pk = client_state.read().await.pk.clone();
for member in members {
let target_client = match state.read().await.clients.get(&(member.lodestone_id as u64)).cloned() {
Some(client) => client,
None => continue,
};
target_client.read().await.tx.send(ResponseContainer {
number: 0,
kind: ResponseKind::SendSecrets(SendSecretsResponse {
channel: req.channel,
request_id,
pk: pk.clone().into(),
}),
}).await?;
}
Ok(())
}

View File

@ -0,0 +1,42 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, ResponseContainer, State, WsStream};
use crate::types::protocol::{ResponseKind, SecretsResponse, SendSecretsRequest};
use crate::util::send;
pub async fn send_secrets(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: SendSecretsRequest) -> Result<()> {
let encrypted = match req.encrypted_shared_secret {
Some(encrypted) if !encrypted.is_empty() => encrypted,
_ => return Ok(()),
};
let info = match state.read().await.secrets_requests.get(&req.request_id).cloned() {
Some(info) => info,
None => return Ok(()),
};
if client_state.read().await.get_rank_invite(info.channel_id, &state).await?.is_none() {
return send(conn, number, ErrorResponse::new(info.channel_id, "not in that channel")).await;
}
state.write().await.secrets_requests.remove(&req.request_id);
let requester = match state.read().await.clients.get(&info.lodestone_id).cloned() {
Some(requester) => requester,
None => return Ok(()),
};
requester.read().await.tx.send(ResponseContainer {
number: info.number,
kind: ResponseKind::Secrets(SecretsResponse {
channel: info.channel_id,
pk: client_state.read().await.pk.clone().into(),
encrypted_shared_secret: encrypted,
}),
}).await.context("failed to send secrets response")?;
Ok(())
}

View File

@ -0,0 +1,39 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use crate::{ClientState, ErrorResponse, Rank, State, WsStream};
use crate::types::protocol::{UpdatedResponse, UpdateKind, UpdateRequest, UpdateResponse};
use crate::util::send;
pub async fn update(state: Arc<RwLock<State>>, client_state: Arc<RwLock<ClientState>>, conn: &mut WsStream, number: u32, req: UpdateRequest) -> Result<()> {
match client_state.read().await.get_rank(req.channel, &state).await? {
Some(rank) if rank == Rank::Admin => {}
_ => return send(conn, number, ErrorResponse::new(req.channel, "not in that channel")).await,
}
let channel_id_str = req.channel.as_simple().to_string();
match &req.kind {
UpdateKind::Name(name) => {
sqlx::query!(
// language=sqlite
"update channels set name = ? where id = ?",
name,
channel_id_str,
)
.execute(&state.read().await.db)
.await
.context("could not update name")?;
}
}
crate::util::send_to_all(&state, req.channel, 0, UpdatedResponse {
channel: req.channel,
kind: req.kind,
}).await?;
send(conn, number, UpdateResponse {
channel: req.channel,
}).await
}

View File

@ -0,0 +1,24 @@
use anyhow::Result;
use crate::{
ErrorResponse,
types::protocol::{
VersionRequest,
VersionResponse,
},
util::send,
WsStream,
};
pub async fn version(conn: &mut WsStream, number: u32, req: VersionRequest) -> Result<bool> {
if req.version != 1 {
send(conn, number, ErrorResponse::new(None, "unsupported version")).await?;
return Ok(false);
}
send(conn, number, VersionResponse {
version: 1,
}).await?;
Ok(true)
}

47
server/src/logging.rs Normal file
View File

@ -0,0 +1,47 @@
use anyhow::{Context, Result};
use lazy_static::lazy_static;
use log::{Level, LevelFilter};
use parking_lot::RwLock;
use regex::Regex;
lazy_static! {
pub static ref LOG_LEVEL: RwLock<Level> = RwLock::new(Level::Info);
static ref KEY_REGEX: Regex = Regex::new(r#"extrachat_[1-9A-HJ-NP-Za-km-z]+_[1-9A-HJ-NP-Za-km-z]+"#).unwrap();
}
pub fn setup() -> Result<()> {
fern::Dispatch::new()
.filter(|metadata| {
match metadata.target() {
"extra_chat_server" | "sqlx" => true,
x if x.starts_with("extra_chat_server::") => true,
x if x.starts_with("sqlx::") => true,
_ => false,
}
})
.format(|out, message, record| {
let message = format!("{}", message);
let message = KEY_REGEX.replace_all(&message, "[redacted]");
out.finish(format_args!(
"[{}][{}][{}:{}] {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z"),
record.level(),
record.file().unwrap_or("?"),
record.line().unwrap_or(0),
message,
))
})
.chain(fern::Dispatch::new()
.filter(|meta| {
meta.level() <= *LOG_LEVEL.read()
})
.chain(std::io::stdout())
)
.chain(fern::Dispatch::new()
.level(LevelFilter::Trace)
.chain(fern::log_file("extrachat.log")?)
)
.apply()
.context("could not set up logging facility")
}

Some files were not shown because too many files have changed in this diff Show More