From c2723fd0056fe361d5a54c641897f5dfd3a9312f Mon Sep 17 00:00:00 2001 From: Loporrit <141286461+loporrit@users.noreply.github.com> Date: Sun, 24 Nov 2024 22:21:41 +0000 Subject: [PATCH] Basic syncshell chat impl with game hooks --- MareAPI | 2 +- MareSynchronos/Interop/GameChatHooks.cs | 205 ++++++++++++++++++ .../Configurations/MareConfig.cs | 2 + .../Configurations/ServerTagConfig.cs | 1 + .../Configurations/SyncshellConfig.cs | 10 + .../Configurations/UidNotesConfig.cs | 1 + .../Models/ServerNotesStorage.cs | 1 + .../Models/ServerShellStorage.cs | 7 + .../MareConfiguration/Models/ShellConfig.cs | 7 + .../SyncshellConfigService.cs | 14 ++ MareSynchronos/MarePlugin.cs | 1 + .../PlayerData/Factories/PairFactory.cs | 3 +- MareSynchronos/Plugin.cs | 11 +- .../Services/ChatIntegrationService.cs | 23 ++ MareSynchronos/Services/ChatService.cs | 157 ++++++++++++++ .../Services/CommandManagerService.cs | 40 +++- MareSynchronos/Services/Mediator/Messages.cs | 2 + .../ServerConfigurationManager.cs | 51 ++++- MareSynchronos/UI/CompactUI.cs | 7 +- MareSynchronos/UI/Components/GroupPanel.cs | 20 +- MareSynchronos/UI/SettingsUi.cs | 13 +- .../SignalR/ApIController.Functions.Users.cs | 6 + .../ApiController.Functions.Callbacks.cs | 27 +++ .../SignalR/ApiController.Functions.Groups.cs | 9 +- .../WebAPI/SignalR/ApiController.cs | 3 + 25 files changed, 610 insertions(+), 13 deletions(-) create mode 100644 MareSynchronos/Interop/GameChatHooks.cs create mode 100644 MareSynchronos/MareConfiguration/Configurations/SyncshellConfig.cs create mode 100644 MareSynchronos/MareConfiguration/Models/ServerShellStorage.cs create mode 100644 MareSynchronos/MareConfiguration/Models/ShellConfig.cs create mode 100644 MareSynchronos/MareConfiguration/SyncshellConfigService.cs create mode 100644 MareSynchronos/Services/ChatIntegrationService.cs create mode 100644 MareSynchronos/Services/ChatService.cs diff --git a/MareAPI b/MareAPI index cd8934a..e007d99 160000 --- a/MareAPI +++ b/MareAPI @@ -1 +1 @@ -Subproject commit cd8934a4ab37a3549bacf7e7108f83a34403da96 +Subproject commit e007d99f025015543340b0e2fa22d212b9b9b57a diff --git a/MareSynchronos/Interop/GameChatHooks.cs b/MareSynchronos/Interop/GameChatHooks.cs new file mode 100644 index 0000000..4583086 --- /dev/null +++ b/MareSynchronos/Interop/GameChatHooks.cs @@ -0,0 +1,205 @@ +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Hooking; +using Dalamud.Memory; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Client.UI.Shell; +using FFXIVClientStructs.FFXIV.Component.Shell; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.Interop; + +public class ChatChannelOverride +{ + public string ChannelName = string.Empty; + public Action? ChatMessageHandler; +} + +public unsafe sealed class GameChatHooks : IDisposable +{ + // Based on https://git.anna.lgbt/anna/ExtraChat/src/branch/main/client/ExtraChat/GameFunctions.cs + + private readonly ILogger _logger; + + #region signatures + // I do not know what kind of black magic this function performs + [Signature("E8 ?? ?? ?? ?? 0F B7 7F 08 48 8B CE")] + private readonly delegate* unmanaged ProcessStringStep2; + + // Component::Shell::ShellCommandModule::ExecuteCommandInner + private delegate void SendMessageDelegate(ShellCommandModule* module, Utf8String* message, UIModule* uiModule); + [Signature( + "E8 ?? ?? ?? ?? FE 86 ?? ?? ?? ?? C7 86", + DetourName = nameof(SendMessageDetour) + )] + private Hook? SendMessageHook { get; init; } + + // Client::UI::Shell::RaptureShellModule::SetChatChannel + private delegate void SetChatChannelDelegate(RaptureShellModule* module, uint channel); + [Signature( + "E8 ?? ?? ?? ?? 33 C0 EB 1D", + DetourName = nameof(SetChatChannelDetour) + )] + private Hook? SetChatChannelHook { get; init; } + + // Component::Shell::ShellCommandModule::ExecuteCommandInner + private delegate byte* ChangeChannelNameDelegate(AgentChatLog* agent); + [Signature( + "E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6", + DetourName = nameof(ChangeChannelNameDetour) + )] + private Hook? ChangeChannelNameHook { get; init; } + + // Client::UI::Agent::AgentChatLog_??? + private delegate byte ShouldDoNameLookupDelegate(AgentChatLog* agent); + [Signature( + "48 89 5C 24 ?? 57 48 83 EC 20 48 8B D9 40 32 FF 48 8B 49 10", + DetourName = nameof(ShouldDoNameLookupDetour) + )] + private Hook? ShouldDoNameLookupHook { get; init; } + + #endregion + + private ChatChannelOverride? _chatChannelOverride; + private bool _shouldForceNameLookup = false; + + public ChatChannelOverride? ChatChannelOverride + { + get => _chatChannelOverride; + set { + _chatChannelOverride = value; + this._shouldForceNameLookup = true; + } + } + + public GameChatHooks(ILogger logger, IGameInteropProvider gameInteropProvider) + { + _logger = logger; + + logger.LogInformation("Initializing GameChatHooks"); + gameInteropProvider.InitializeFromAttributes(this); + + this.SendMessageHook?.Enable(); + this.SetChatChannelHook?.Enable(); + this.ChangeChannelNameHook?.Enable(); + this.ShouldDoNameLookupHook?.Enable(); + } + + public void Dispose() + { + this.SendMessageHook?.Dispose(); + this.SetChatChannelHook?.Dispose(); + this.ChangeChannelNameHook?.Dispose(); + this.ShouldDoNameLookupHook?.Dispose(); + } + + private void SendMessageDetour(ShellCommandModule* thisPtr, Utf8String* message, UIModule* uiModule) + { + try + { + var messageLength = message->Length; + var messageSpan = message->AsSpan(); + + bool isCommand = false; + + // Check if chat input begins with a command (or auto-translated command) + if (messageLength == 0 || messageSpan[0] == (byte)'/' || !messageSpan.ContainsAnyExcept((byte)' ')) + { + isCommand = true; + } + else if (messageSpan[0] == (byte)0x02) /* Payload.START_BYTE */ + { + var payload = Payload.Decode(new BinaryReader(new UnmanagedMemoryStream(message->StringPtr, message->BufSize))) as AutoTranslatePayload; + + // Auto-translate text begins with / + if (payload != null && payload.Text.Length > 2 && payload.Text[2] == '/') + isCommand = true; + } + + // If not a command, or no override is set, then call the original chat handler + if (isCommand || this._chatChannelOverride == null) + { + SendMessageHook!.OriginalDisposeSafe(thisPtr, message, uiModule); + return; + } + + // Otherwise, the text is to be sent to the emulated chat channel handler + // The chat input string is rendered in to a payload for display first + var pronounModule = UIModule.Instance()->GetPronounModule(); + var chatString1 = pronounModule->ProcessString(message, true); + var chatString2 = this.ProcessStringStep2(pronounModule, chatString1, 1); + var chatBytes = MemoryHelper.ReadRaw((nint)chatString2->StringPtr, chatString2->Length); + + if (chatBytes.Length > 0) + this._chatChannelOverride.ChatMessageHandler?.Invoke(chatBytes); + } + catch (Exception e) + { + _logger.LogError(e, "Exception thrown during SendMessageDetour"); + } + } + + private void SetChatChannelDetour(RaptureShellModule* module, uint channel) + { + try + { + if (this._chatChannelOverride != null) + { + this._chatChannelOverride = null; + this._shouldForceNameLookup = true; + } + } + catch (Exception e) + { + _logger.LogError(e, "Exception thrown during SetChatChannelDetour"); + } + + SetChatChannelHook!.OriginalDisposeSafe(module, channel); + } + + private byte* ChangeChannelNameDetour(AgentChatLog* agent) + { + var originalResult = ChangeChannelNameHook!.OriginalDisposeSafe(agent); + + try + { + // Replace the chat channel name on the UI if active + if (this._chatChannelOverride != null) + { + agent->ChannelLabel.SetString(this._chatChannelOverride.ChannelName); + } + } + catch (Exception e) + { + _logger.LogError(e, "Exception thrown during ChangeChannelNameDetour"); + } + + return originalResult; + } + + private byte ShouldDoNameLookupDetour(AgentChatLog* agent) + { + var originalResult = ShouldDoNameLookupHook!.OriginalDisposeSafe(agent); + + try + { + // Force the chat channel name to update when required + if (this._shouldForceNameLookup) + { + _shouldForceNameLookup = false; + return 1; + } + } + catch (Exception e) + { + _logger.LogError(e, "Exception thrown during ShouldDoNameLookupDetour"); + } + + return originalResult; + } +} diff --git a/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs b/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs index cf6e36d..35fe22d 100644 --- a/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs +++ b/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs @@ -54,4 +54,6 @@ public class MareConfig : IMareConfiguration public bool UseCompactor { get; set; } = false; public int Version { get; set; } = 1; public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both; + + public bool DisableSyncshellChat { get; set; } = false; } \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/Configurations/ServerTagConfig.cs b/MareSynchronos/MareConfiguration/Configurations/ServerTagConfig.cs index 4150f2e..c6c8500 100644 --- a/MareSynchronos/MareConfiguration/Configurations/ServerTagConfig.cs +++ b/MareSynchronos/MareConfiguration/Configurations/ServerTagConfig.cs @@ -2,6 +2,7 @@ namespace MareSynchronos.MareConfiguration.Configurations; +[Serializable] public class ServerTagConfig : IMareConfiguration { public Dictionary ServerTagStorage { get; set; } = new(StringComparer.OrdinalIgnoreCase); diff --git a/MareSynchronos/MareConfiguration/Configurations/SyncshellConfig.cs b/MareSynchronos/MareConfiguration/Configurations/SyncshellConfig.cs new file mode 100644 index 0000000..86989e0 --- /dev/null +++ b/MareSynchronos/MareConfiguration/Configurations/SyncshellConfig.cs @@ -0,0 +1,10 @@ +using MareSynchronos.MareConfiguration.Models; + +namespace MareSynchronos.MareConfiguration.Configurations; + +[Serializable] +public class SyncshellConfig : IMareConfiguration +{ + public Dictionary ServerShellStorage { get; set; } = new(StringComparer.Ordinal); + public int Version { get; set; } = 0; +} \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs b/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs index 0129eb2..7924c12 100644 --- a/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs +++ b/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs @@ -2,6 +2,7 @@ namespace MareSynchronos.MareConfiguration.Configurations; +[Serializable] public class UidNotesConfig : IMareConfiguration { public Dictionary ServerNotes { get; set; } = new(StringComparer.Ordinal); diff --git a/MareSynchronos/MareConfiguration/Models/ServerNotesStorage.cs b/MareSynchronos/MareConfiguration/Models/ServerNotesStorage.cs index 0f2ef9e..75ea221 100644 --- a/MareSynchronos/MareConfiguration/Models/ServerNotesStorage.cs +++ b/MareSynchronos/MareConfiguration/Models/ServerNotesStorage.cs @@ -1,5 +1,6 @@ namespace MareSynchronos.MareConfiguration.Models; +[Serializable] public class ServerNotesStorage { public Dictionary GidServerComments { get; set; } = new(StringComparer.Ordinal); diff --git a/MareSynchronos/MareConfiguration/Models/ServerShellStorage.cs b/MareSynchronos/MareConfiguration/Models/ServerShellStorage.cs new file mode 100644 index 0000000..2f9fa2a --- /dev/null +++ b/MareSynchronos/MareConfiguration/Models/ServerShellStorage.cs @@ -0,0 +1,7 @@ +namespace MareSynchronos.MareConfiguration.Models; + +[Serializable] +public class ServerShellStorage +{ + public Dictionary GidShellConfig { get; set; } = new(StringComparer.Ordinal); +} \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/Models/ShellConfig.cs b/MareSynchronos/MareConfiguration/Models/ShellConfig.cs new file mode 100644 index 0000000..05c8f37 --- /dev/null +++ b/MareSynchronos/MareConfiguration/Models/ShellConfig.cs @@ -0,0 +1,7 @@ +namespace MareSynchronos.MareConfiguration.Models; + +[Serializable] +public class ShellConfig +{ + public int ShellNumber { get; set; } +} \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/SyncshellConfigService.cs b/MareSynchronos/MareConfiguration/SyncshellConfigService.cs new file mode 100644 index 0000000..c564b9c --- /dev/null +++ b/MareSynchronos/MareConfiguration/SyncshellConfigService.cs @@ -0,0 +1,14 @@ +using MareSynchronos.MareConfiguration.Configurations; + +namespace MareSynchronos.MareConfiguration; + +public class SyncshellConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "syncshells.json"; + + public SyncshellConfigService(string configDir) : base(configDir) + { + } + + protected override string ConfigurationName => ConfigName; +} \ No newline at end of file diff --git a/MareSynchronos/MarePlugin.cs b/MareSynchronos/MarePlugin.cs index f8e83bb..8af332a 100644 --- a/MareSynchronos/MarePlugin.cs +++ b/MareSynchronos/MarePlugin.cs @@ -147,6 +147,7 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService _runtimeServiceScope.ServiceProvider.GetRequiredService(); _runtimeServiceScope.ServiceProvider.GetRequiredService(); _runtimeServiceScope.ServiceProvider.GetRequiredService(); + _runtimeServiceScope.ServiceProvider.GetRequiredService(); #if !DEBUG if (_mareConfigService.Current.LogLevel != LogLevel.Information) diff --git a/MareSynchronos/PlayerData/Factories/PairFactory.cs b/MareSynchronos/PlayerData/Factories/PairFactory.cs index fa3b110..b69f95d 100644 --- a/MareSynchronos/PlayerData/Factories/PairFactory.cs +++ b/MareSynchronos/PlayerData/Factories/PairFactory.cs @@ -1,4 +1,5 @@ -using MareSynchronos.MareConfiguration; +using MareSynchronos.API.Dto.Group; +using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index 73ef245..efca1b9 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -46,7 +46,7 @@ public sealed class Plugin : IDalamudPlugin public Plugin(IDalamudPluginInterface pluginInterface, ICommandManager commandManager, IDataManager gameData, IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui, IGameGui gameGui, IDtrBar dtrBar, IToastGui toastGui, IPluginLog pluginLog, ITargetManager targetManager, IGameLifecycle addonLifecycle, - INotificationManager notificationManager, ITextureProvider textureProvider, IContextMenu contextMenu) + INotificationManager notificationManager, ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider) { Plugin.Self = this; _hostBuilderRunTask = new HostBuilder() @@ -103,6 +103,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new NotesConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new ServerTagConfigService(pluginInterface.ConfigDirectory.FullName)); + collection.AddSingleton((s) => new SyncshellConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new TransientConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new ConfigurationMigrator(s.GetRequiredService>(), pluginInterface, s.GetRequiredService())); collection.AddSingleton(); @@ -133,13 +134,17 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new NotificationService(s.GetRequiredService>(), s.GetRequiredService(), notificationManager, chatGui, s.GetRequiredService())); collection.AddScoped((s) => new UiSharedService(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), pluginInterface, textureProvider, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddScoped((s) => new ChatService(s.GetRequiredService>(), s.GetRequiredService(), + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), + s.GetRequiredService>(), gameInteropProvider, chatGui, + s.GetRequiredService(), s.GetRequiredService())); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); diff --git a/MareSynchronos/Services/ChatIntegrationService.cs b/MareSynchronos/Services/ChatIntegrationService.cs new file mode 100644 index 0000000..c97e264 --- /dev/null +++ b/MareSynchronos/Services/ChatIntegrationService.cs @@ -0,0 +1,23 @@ +using MareSynchronos.Services.Mediator; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.Services; + +public sealed class ChatIntegrationService : MediatorSubscriberBase, IDisposable +{ + public bool Activated { get; private set; } = false; + + public ChatIntegrationService(ILogger logger, MareMediator mediator) : base(logger, mediator) + { + } + + public void Activate() + { + if (Activated) + return; + } + + public void Dispose() + { + } +} \ No newline at end of file diff --git a/MareSynchronos/Services/ChatService.cs b/MareSynchronos/Services/ChatService.cs new file mode 100644 index 0000000..e9888da --- /dev/null +++ b/MareSynchronos/Services/ChatService.cs @@ -0,0 +1,157 @@ +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Plugin.Services; +using MareSynchronos.API.Data; +using MareSynchronos.Interop; +using MareSynchronos.MareConfiguration; +using MareSynchronos.PlayerData.Pairs; +using MareSynchronos.Services.Mediator; +using MareSynchronos.Services.ServerConfiguration; +using MareSynchronos.WebAPI; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.Services; + +public class ChatService : DisposableMediatorSubscriberBase +{ + private readonly ILogger _logger; + private readonly IChatGui _chatGui; + private readonly DalamudUtilService _dalamudUtil; + private readonly MareConfigService _mareConfig; + private readonly ApiController _apiController; + private readonly PairManager _pairManager; + private readonly ServerConfigurationManager _serverConfigurationManager; + + private readonly Lazy _gameChatHooks; + + public ChatService(ILogger logger, DalamudUtilService dalamudUtil, MareMediator mediator, ApiController apiController, + PairManager pairManager, ILogger logger2, IGameInteropProvider gameInteropProvider, IChatGui chatGui, + MareConfigService mareConfig, ServerConfigurationManager serverConfigurationManager) : base(logger, mediator) + { + _logger = logger; + _dalamudUtil = dalamudUtil; + _chatGui = chatGui; + _mareConfig = mareConfig; + _apiController = apiController; + _pairManager = pairManager; + _serverConfigurationManager = serverConfigurationManager; + + Mediator.Subscribe(this, HandleUserChat); + Mediator.Subscribe(this, HandleGroupChat); + + _gameChatHooks = new(() => new GameChatHooks(logger2, gameInteropProvider)); + } + + protected override void Dispose(bool disposing) + { + if (_gameChatHooks.IsValueCreated) + _gameChatHooks.Value!.Dispose(); + } + + private void HandleUserChat(UserChatMsgMessage message) + { + var chatMsg = message.ChatMsg; + var prefix = new SeStringBuilder(); + prefix.AddText("[BnnuyChat] "); + _chatGui.Print(new XivChatEntry{ + MessageBytes = [..prefix.Build().Encode(), ..message.ChatMsg.PayloadContent], + Name = chatMsg.SenderName, + Type = XivChatType.TellIncoming + }); + } + + private void HandleGroupChat(GroupChatMsgMessage message) + { + if (_mareConfig.Current.DisableSyncshellChat) + return; + + var chatMsg = message.ChatMsg; + var shellNumber = _serverConfigurationManager.GetShellNumberForGid(message.GroupInfo.GID); + var prefix = new SeStringBuilder(); + + // TODO: Configure colors and appearance + prefix.AddUiForeground(710); + prefix.AddText($"[SS{shellNumber}]<"); + // TODO: Don't link to the local player because it lets you do invalid things + prefix.Add(new PlayerPayload(chatMsg.SenderName, (uint)chatMsg.SenderHomeWorldId)); + prefix.AddText("> "); + + _chatGui.Print(new XivChatEntry{ + MessageBytes = [..prefix.Build().Encode(), ..message.ChatMsg.PayloadContent], + Name = chatMsg.SenderName, + Type = XivChatType.Debug + }); + } + + // Called to update the active chat shell name if its renamed + public void MaybeUpdateShellName(int shellNumber) + { + if (_mareConfig.Current.DisableSyncshellChat) + return; + + foreach (var group in _pairManager.Groups) + { + if (_serverConfigurationManager.GetShellNumberForGid(group.Key.GID) == shellNumber) + { + if (_gameChatHooks.IsValueCreated && _gameChatHooks.Value.ChatChannelOverride != null) + { + // Very dumb and won't handle re-numbering -- need to identify the active chat channel more reliably later + if (_gameChatHooks.Value.ChatChannelOverride.ChannelName.StartsWith($"SS [{shellNumber}]")) + SwitchChatShell(shellNumber); + } + } + } + } + + public void SwitchChatShell(int shellNumber) + { + if (_mareConfig.Current.DisableSyncshellChat) + return; + + foreach (var group in _pairManager.Groups) + { + if (_serverConfigurationManager.GetShellNumberForGid(group.Key.GID) == shellNumber) + { + var name = _serverConfigurationManager.GetNoteForGid(group.Key.GID) ?? group.Key.AliasOrGID; + // BUG: This doesn't always update the chat window e.g. when renaming a group + _gameChatHooks.Value.ChatChannelOverride = new() + { + ChannelName = $"SS [{shellNumber}]: {name}", + ChatMessageHandler = chatBytes => SendChatShell(shellNumber, chatBytes) + }; + return; + } + } + + _chatGui.PrintError($"[LoporritSync] Syncshell number #{shellNumber} not found"); + } + + public void SendChatShell(int shellNumber, byte[] chatBytes) + { + if (_mareConfig.Current.DisableSyncshellChat) + return; + + foreach (var group in _pairManager.Groups) + { + if (_serverConfigurationManager.GetShellNumberForGid(group.Key.GID) == shellNumber) + { + Task.Run(async () => { + // TODO: Cache the name and home world instead of fetching it every time + var chatMsg = await _dalamudUtil.RunOnFrameworkThread(() => { + return new ChatMessage() + { + SenderName = _dalamudUtil.GetPlayerName(), + SenderHomeWorldId = _dalamudUtil.GetHomeWorldId(), + PayloadContent = chatBytes + }; + }); + await _apiController.GroupChatSendMsg(new(group.Key), chatMsg); + }); + return; + } + } + + _chatGui.PrintError($"[LoporritSync] Syncshell number #{shellNumber} not found"); + } +} \ No newline at end of file diff --git a/MareSynchronos/Services/CommandManagerService.cs b/MareSynchronos/Services/CommandManagerService.cs index a93ab08..7de2cec 100644 --- a/MareSynchronos/Services/CommandManagerService.cs +++ b/MareSynchronos/Services/CommandManagerService.cs @@ -1,4 +1,5 @@ using Dalamud.Game.Command; +using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; using MareSynchronos.FileCache; @@ -8,6 +9,7 @@ using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.UI; using MareSynchronos.WebAPI; using System.Globalization; +using System.Text; namespace MareSynchronos.Services; @@ -16,22 +18,27 @@ public sealed class CommandManagerService : IDisposable private const string _commandName = "/sync"; private const string _commandName2 = "/loporrit"; + private const string _ssCommandPrefix = "/ss"; + private const int _ssCommandMaxNumber = 50; + private readonly ApiController _apiController; private readonly ICommandManager _commandManager; private readonly MareMediator _mediator; private readonly MareConfigService _mareConfigService; private readonly PerformanceCollectorService _performanceCollectorService; private readonly PeriodicFileScanner _periodicFileScanner; + private readonly ChatService _chatService; private readonly ServerConfigurationManager _serverConfigurationManager; public CommandManagerService(ICommandManager commandManager, PerformanceCollectorService performanceCollectorService, - ServerConfigurationManager serverConfigurationManager, PeriodicFileScanner periodicFileScanner, + ServerConfigurationManager serverConfigurationManager, PeriodicFileScanner periodicFileScanner, ChatService chatService, ApiController apiController, MareMediator mediator, MareConfigService mareConfigService) { _commandManager = commandManager; _performanceCollectorService = performanceCollectorService; _serverConfigurationManager = serverConfigurationManager; _periodicFileScanner = periodicFileScanner; + _chatService = chatService; _apiController = apiController; _mediator = mediator; _mareConfigService = mareConfigService; @@ -43,12 +50,24 @@ public sealed class CommandManagerService : IDisposable { HelpMessage = "Opens the Loporrit UI" }); + + // Lazy registration of all possible /ss# commands which tbf is what the game does for linkshells anyway + for (int i = 1; i <= _ssCommandMaxNumber; ++i) + { + _commandManager.AddHandler($"{_ssCommandPrefix}{i}", new CommandInfo(OnChatCommand) + { + ShowInHelp = false + }); + } } public void Dispose() { _commandManager.RemoveHandler(_commandName); _commandManager.RemoveHandler(_commandName2); + + for (int i = 1; i <= _ssCommandMaxNumber; ++i) + _commandManager.RemoveHandler($"{_ssCommandPrefix}{i}"); } private void OnCommand(string command, string args) @@ -116,4 +135,23 @@ public sealed class CommandManagerService : IDisposable _mediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); } } + + private void OnChatCommand(string command, string args) + { + if (_mareConfigService.Current.DisableSyncshellChat) + return; + + int shellNumber = int.Parse(command[_ssCommandPrefix.Length..]); + + if (args.Length == 0) + { + _chatService.SwitchChatShell(shellNumber); + } + else + { + // FIXME: Chat content seems to already be stripped of any special characters here? + byte[] chatBytes = Encoding.UTF8.GetBytes(args); + _chatService.SendChatShell(shellNumber, chatBytes); + } + } } \ No newline at end of file diff --git a/MareSynchronos/Services/Mediator/Messages.cs b/MareSynchronos/Services/Mediator/Messages.cs index 7d4e024..28c7caa 100644 --- a/MareSynchronos/Services/Mediator/Messages.cs +++ b/MareSynchronos/Services/Mediator/Messages.cs @@ -79,5 +79,7 @@ public record TargetPairMessage(Pair Pair) : MessageBase; public record CombatStartMessage : MessageBase; public record CombatEndMessage : MessageBase; +public record UserChatMsgMessage(SignedChatMessage ChatMsg) : MessageBase; +public record GroupChatMsgMessage(GroupDto GroupInfo, SignedChatMessage ChatMsg) : MessageBase; #pragma warning restore S2094 #pragma warning restore MA0048 // File name must match type name \ No newline at end of file diff --git a/MareSynchronos/Services/ServerConfiguration/ServerConfigurationManager.cs b/MareSynchronos/Services/ServerConfiguration/ServerConfigurationManager.cs index 8015635..7993581 100644 --- a/MareSynchronos/Services/ServerConfiguration/ServerConfigurationManager.cs +++ b/MareSynchronos/Services/ServerConfiguration/ServerConfigurationManager.cs @@ -15,14 +15,16 @@ public class ServerConfigurationManager private readonly MareMediator _mareMediator; private readonly NotesConfigService _notesConfig; private readonly ServerTagConfigService _serverTagConfig; + private readonly SyncshellConfigService _syncshellConfig; public ServerConfigurationManager(ILogger logger, ServerConfigService configService, - ServerTagConfigService serverTagConfig, NotesConfigService notesConfig, DalamudUtilService dalamudUtil, - MareMediator mareMediator) + ServerTagConfigService serverTagConfig, SyncshellConfigService syncshellConfig, NotesConfigService notesConfig, + DalamudUtilService dalamudUtil, MareMediator mareMediator) { _logger = logger; _configService = configService; _serverTagConfig = serverTagConfig; + _syncshellConfig = syncshellConfig; _notesConfig = notesConfig; _dalamudUtil = dalamudUtil; _mareMediator = mareMediator; @@ -244,6 +246,18 @@ public class ServerConfigurationManager return CurrentServerTagStorage().ServerAvailablePairTags; } + internal int GetShellNumberForGid(string gid) + { + if (CurrentSyncshellStorage().GidShellConfig.TryGetValue(gid, out var config)) + { + return config.ShellNumber; + } + + int newNumber = CurrentSyncshellStorage().GidShellConfig.Count > 0 ? CurrentSyncshellStorage().GidShellConfig.Select(x => x.Value.ShellNumber).Max() + 1 : 1; + SetShellNumberForGid(gid, newNumber, false); + return newNumber; + } + internal Dictionary> GetUidServerPairedUserTags() { return CurrentServerTagStorage().UidServerPairedUserTags; @@ -345,6 +359,25 @@ public class ServerConfigurationManager _notesConfig.Save(); } + internal void SetShellNumberForGid(string gid, int number, bool save = true) + { + if (string.IsNullOrEmpty(gid)) return; + + if (CurrentSyncshellStorage().GidShellConfig.TryGetValue(gid, out var config)) + { + config.ShellNumber = number; + } + else + { + CurrentSyncshellStorage().GidShellConfig.Add(gid, new(){ + ShellNumber = number + }); + } + + if (save) + _syncshellConfig.Save(); + } + private ServerNotesStorage CurrentNotesStorage() { TryCreateCurrentNotesStorage(); @@ -357,6 +390,12 @@ public class ServerConfigurationManager return _serverTagConfig.Current.ServerTagStorage[CurrentApiUrl]; } + private ServerShellStorage CurrentSyncshellStorage() + { + TryCreateCurrentSyncshellStorage(); + return _syncshellConfig.Current.ServerShellStorage[CurrentApiUrl]; + } + private void EnsureMainExists() { bool lopExists = false; @@ -406,4 +445,12 @@ public class ServerConfigurationManager _serverTagConfig.Current.ServerTagStorage[CurrentApiUrl] = new(); } } + + private void TryCreateCurrentSyncshellStorage() + { + if (!_syncshellConfig.Current.ServerShellStorage.ContainsKey(CurrentApiUrl)) + { + _syncshellConfig.Current.ServerShellStorage[CurrentApiUrl] = new(); + } + } } \ No newline at end of file diff --git a/MareSynchronos/UI/CompactUI.cs b/MareSynchronos/UI/CompactUI.cs index 8344e5e..28edfcf 100644 --- a/MareSynchronos/UI/CompactUI.cs +++ b/MareSynchronos/UI/CompactUI.cs @@ -15,6 +15,7 @@ using MareSynchronos.API.Dto.User; using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Handlers; using MareSynchronos.PlayerData.Pairs; +using MareSynchronos.Services; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.UI.Components; @@ -38,6 +39,7 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly GroupPanel _groupPanel; private readonly PairGroupsUi _pairGroupsUi; private readonly PairManager _pairManager; + private readonly ChatService _chatService; private readonly SelectGroupForPairUi _selectGroupForPairUi; private readonly SelectPairForGroupUi _selectPairsForGroupUi; private readonly ServerConfigurationManager _serverManager; @@ -56,19 +58,20 @@ public class CompactUi : WindowMediatorSubscriberBase private bool _showSyncShells; private bool _wasOpen; - public CompactUi(ILogger logger, UiSharedService uiShared, MareConfigService configService, ApiController apiController, PairManager pairManager, + public CompactUi(ILogger logger, UiSharedService uiShared, MareConfigService configService, ApiController apiController, PairManager pairManager, ChatService chatService, ServerConfigurationManager serverManager, MareMediator mediator, FileUploadManager fileTransferManager, UidDisplayHandler uidDisplayHandler) : base(logger, mediator, "###LoporritSyncMainUI") { _uiShared = uiShared; _configService = configService; _apiController = apiController; _pairManager = pairManager; + _chatService = chatService; _serverManager = serverManager; _fileTransferManager = fileTransferManager; _uidDisplayHandler = uidDisplayHandler; var tagHandler = new TagHandler(_serverManager); - _groupPanel = new(this, uiShared, _pairManager, uidDisplayHandler, _serverManager); + _groupPanel = new(this, uiShared, _pairManager, _chatService, uidDisplayHandler, _configService, _serverManager); _selectGroupForPairUi = new(tagHandler, uidDisplayHandler); _selectPairsForGroupUi = new(tagHandler, uidDisplayHandler); _pairGroupsUi = new(configService, tagHandler, uidDisplayHandler, apiController, _selectPairsForGroupUi); diff --git a/MareSynchronos/UI/Components/GroupPanel.cs b/MareSynchronos/UI/Components/GroupPanel.cs index 90e886f..2327642 100644 --- a/MareSynchronos/UI/Components/GroupPanel.cs +++ b/MareSynchronos/UI/Components/GroupPanel.cs @@ -17,6 +17,9 @@ using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.UI.Components; using MareSynchronos.UI.Handlers; using Dalamud.Interface.Utility; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using MareSynchronos.Services; +using MareSynchronos.MareConfiguration; namespace MareSynchronos.UI; @@ -25,6 +28,8 @@ internal sealed class GroupPanel private readonly Dictionary _expandedGroupState = new(StringComparer.Ordinal); private readonly CompactUi _mainUi; private readonly PairManager _pairManager; + private readonly ChatService _chatService; + private readonly MareConfigService _mareConfig; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly Dictionary _showGidForEntry = new(StringComparer.Ordinal); private readonly UidDisplayHandler _uidDisplayHandler; @@ -50,12 +55,15 @@ internal sealed class GroupPanel private string _syncShellPassword = string.Empty; private string _syncShellToJoin = string.Empty; - public GroupPanel(CompactUi mainUi, UiSharedService uiShared, PairManager pairManager, UidDisplayHandler uidDisplayHandler, ServerConfigurationManager serverConfigurationManager) + public GroupPanel(CompactUi mainUi, UiSharedService uiShared, PairManager pairManager, ChatService chatServivce, + UidDisplayHandler uidDisplayHandler, MareConfigService mareConfig, ServerConfigurationManager serverConfigurationManager) { _mainUi = mainUi; _uiShared = uiShared; _pairManager = pairManager; + _chatService = chatServivce; _uidDisplayHandler = uidDisplayHandler; + _mareConfig = mareConfig; _serverConfigurationManager = serverConfigurationManager; } @@ -185,6 +193,8 @@ internal sealed class GroupPanel private void DrawSyncshell(GroupFullInfoDto groupDto, List pairsInGroup) { + int shellNumber = _serverConfigurationManager.GetShellNumberForGid(groupDto.GID); + var name = groupDto.Group.Alias ?? groupDto.GID; if (!_expandedGroupState.TryGetValue(groupDto.GID, out bool isExpanded)) { @@ -230,7 +240,13 @@ internal sealed class GroupPanel if (!string.Equals(_editGroupEntry, groupDto.GID, StringComparison.Ordinal)) { + if (!_mareConfig.Current.DisableSyncshellChat) + { + ImGui.TextUnformatted($"[{shellNumber}]"); + UiSharedService.AttachToolTip("Chat command prefix: /ss" + shellNumber); + } if (textIsGid) ImGui.PushFont(UiBuilder.MonoFont); + ImGui.SameLine(); ImGui.TextUnformatted(groupName); if (textIsGid) ImGui.PopFont(); UiSharedService.AttachToolTip("Left click to switch between GID display and comment" + Environment.NewLine + @@ -252,6 +268,7 @@ internal sealed class GroupPanel _serverConfigurationManager.SetNoteForGid(_editGroupEntry, _editGroupComment); _editGroupComment = _serverConfigurationManager.GetNoteForGid(groupDto.GID) ?? string.Empty; _editGroupEntry = groupDto.GID; + _chatService.MaybeUpdateShellName(shellNumber); } } else @@ -262,6 +279,7 @@ internal sealed class GroupPanel { _serverConfigurationManager.SetNoteForGid(groupDto.GID, _editGroupComment); _editGroupEntry = string.Empty; + _chatService.MaybeUpdateShellName(shellNumber); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) diff --git a/MareSynchronos/UI/SettingsUi.cs b/MareSynchronos/UI/SettingsUi.cs index 5fb6f15..9d18a27 100644 --- a/MareSynchronos/UI/SettingsUi.cs +++ b/MareSynchronos/UI/SettingsUi.cs @@ -651,6 +651,18 @@ public class SettingsUi : WindowMediatorSubscriberBase } UiSharedService.DrawHelpText("This will open a popup that allows you to set the notes for a user after successfully adding them to your individual pairs."); + ImGui.Separator(); + UiSharedService.FontText("Chat", _uiShared.UidFont); + + var disableSyncshellChat = _configService.Current.DisableSyncshellChat; + + if (ImGui.Checkbox("Disable Syncshell Chat", ref disableSyncshellChat)) + { + _configService.Current.DisableSyncshellChat = disableSyncshellChat; + _configService.Save(); + } + UiSharedService.DrawHelpText("Disable sending/receiving all syncshell chat messages."); + ImGui.Separator(); UiSharedService.FontText("UI", _uiShared.UidFont); var showCharacterNames = _configService.Current.ShowCharacterNames; @@ -664,7 +676,6 @@ public class SettingsUi : WindowMediatorSubscriberBase var enableDtrEntry = _configService.Current.EnableDtrEntry; var showUidInDtrTooltip = _configService.Current.ShowUidInDtrTooltip; var preferNoteInDtrTooltip = _configService.Current.PreferNoteInDtrTooltip; - var preferNotesInsteadOfName = _configService.Current.PreferNotesOverNamesForVisible; if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu)) { diff --git a/MareSynchronos/WebAPI/SignalR/ApIController.Functions.Users.cs b/MareSynchronos/WebAPI/SignalR/ApIController.Functions.Users.cs index 657c8b1..08862d3 100644 --- a/MareSynchronos/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/MareSynchronos/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -33,6 +33,12 @@ public partial class ApiController await _mareHub!.SendAsync(nameof(UserAddPair), user).ConfigureAwait(false); } + public async Task UserChatSendMsg(UserDto user, ChatMessage message) + { + CheckConnection(); + await _mareHub!.SendAsync(nameof(UserChatSendMsg), user, message).ConfigureAwait(false); + } + public async Task UserDelete() { CheckConnection(); diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index fbd12f8..18c47f6 100644 --- a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -1,6 +1,7 @@ using Dalamud.Interface.ImGuiNotification; using MareSynchronos.API.Data.Enum; using MareSynchronos.API.Dto; +using MareSynchronos.API.Dto.Chat; using MareSynchronos.API.Dto.Group; using MareSynchronos.API.Dto.User; using MareSynchronos.Services.Mediator; @@ -25,6 +26,13 @@ public partial class ApiController return Task.CompletedTask; } + public Task Client_GroupChatMsg(GroupChatMsgDto groupChatMsgDto) + { + Logger.LogDebug("Client_GroupChatMsg: {msg}", groupChatMsgDto.Message); + Mediator.Publish(new GroupChatMsgMessage(groupChatMsgDto.Group, groupChatMsgDto.Message)); + return Task.CompletedTask; + } + public Task Client_GroupPairChangePermissions(GroupPairUserPermissionDto dto) { Logger.LogTrace("Client_GroupPairChangePermissions: {dto}", dto); @@ -120,6 +128,13 @@ public partial class ApiController return Task.CompletedTask; } + public Task Client_UserChatMsg(UserChatMsgDto chatMsgDto) + { + Logger.LogDebug("Client_UserChatMsg: {msg}", chatMsgDto.Message); + Mediator.Publish(new UserChatMsgMessage(chatMsgDto.Message)); + return Task.CompletedTask; + } + public Task Client_UserReceiveCharacterData(OnlineUserCharaDataDto dataDto) { Logger.LogTrace("Client_UserReceiveCharacterData: {user}", dataDto.User); @@ -188,6 +203,12 @@ public partial class ApiController _mareHub!.On(nameof(Client_GroupChangePermissions), act); } + public void OnGroupChatMsg(Action act) + { + if (_initialized) return; + _mareHub!.On(nameof(Client_GroupChatMsg), act); + } + public void OnGroupPairChangePermissions(Action act) { if (_initialized) return; @@ -248,6 +269,12 @@ public partial class ApiController _mareHub!.On(nameof(Client_UserAddClientPair), act); } + public void OnUserChatMsg(Action act) + { + if (_initialized) return; + _mareHub!.On(nameof(Client_UserChatMsg), act); + } + public void OnUserReceiveCharacterData(Action act) { if (_initialized) return; diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Groups.cs b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Groups.cs index 62a67f5..7a0f54c 100644 --- a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Groups.cs +++ b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Groups.cs @@ -1,4 +1,5 @@ -using MareSynchronos.API.Dto.Group; +using MareSynchronos.API.Data; +using MareSynchronos.API.Dto.Group; using MareSynchronos.WebAPI.SignalR.Utils; using Microsoft.AspNetCore.SignalR.Client; @@ -36,6 +37,12 @@ public partial class ApiController return await _mareHub!.InvokeAsync(nameof(GroupChangePassword), groupPassword).ConfigureAwait(false); } + public async Task GroupChatSendMsg(GroupDto group, ChatMessage message) + { + CheckConnection(); + await _mareHub!.SendAsync(nameof(GroupChatSendMsg), group, message).ConfigureAwait(false); + } + public async Task GroupClear(GroupDto group) { CheckConnection(); diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.cs b/MareSynchronos/WebAPI/SignalR/ApiController.cs index 798af0f..a0a02a8 100644 --- a/MareSynchronos/WebAPI/SignalR/ApiController.cs +++ b/MareSynchronos/WebAPI/SignalR/ApiController.cs @@ -323,6 +323,9 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IM OnGroupSendInfo((dto) => _ = Client_GroupSendInfo(dto)); OnGroupPairChangePermissions((dto) => _ = Client_GroupPairChangePermissions(dto)); + OnUserChatMsg((dto) => _ = Client_UserChatMsg(dto)); + OnGroupChatMsg((dto) => _ = Client_GroupChatMsg(dto)); + _healthCheckTokenSource?.Cancel(); _healthCheckTokenSource?.Dispose(); _healthCheckTokenSource = new CancellationTokenSource();