using LightlessSync; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.Gui.ContextMenu; using Dalamud.Plugin; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; using Lumina.Excel.Sheets; using LightlessSync.UI.Services; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; internal class ContextMenuService : IHostedService { private readonly IContextMenu _contextMenu; private readonly IDalamudPluginInterface _pluginInterface; private readonly IDataManager _gameData; private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtil; private readonly IClientState _clientState; private readonly PairUiService _pairUiService; private readonly PairRequestService _pairRequestService; private readonly ApiController _apiController; private readonly IObjectTable _objectTable; private readonly LightlessConfigService _configService; private readonly BroadcastScannerService _broadcastScannerService; private readonly BroadcastService _broadcastService; private readonly LightlessProfileManager _lightlessProfileManager; private readonly LightlessMediator _mediator; public ContextMenuService( IContextMenu contextMenu, IDalamudPluginInterface pluginInterface, IDataManager gameData, ILogger logger, DalamudUtilService dalamudUtil, ApiController apiController, IObjectTable objectTable, LightlessConfigService configService, PairRequestService pairRequestService, PairUiService pairUiService, IClientState clientState, BroadcastScannerService broadcastScannerService, BroadcastService broadcastService, LightlessProfileManager lightlessProfileManager, LightlessMediator mediator) { _contextMenu = contextMenu; _pluginInterface = pluginInterface; _gameData = gameData; _logger = logger; _dalamudUtil = dalamudUtil; _apiController = apiController; _objectTable = objectTable; _configService = configService; _pairUiService = pairUiService; _pairRequestService = pairRequestService; _clientState = clientState; _broadcastScannerService = broadcastScannerService; _broadcastService = broadcastService; _lightlessProfileManager = lightlessProfileManager; _mediator = mediator; } public Task StartAsync(CancellationToken cancellationToken) { _contextMenu.OnMenuOpened += OnMenuOpened; return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { _contextMenu.OnMenuOpened -= OnMenuOpened; return Task.CompletedTask; } public void Enable() { _contextMenu.OnMenuOpened += OnMenuOpened; _logger.LogDebug("Context menu enabled."); } public void Disable() { _contextMenu.OnMenuOpened -= OnMenuOpened; _logger.LogDebug("Context menu disabled."); } private void OnMenuOpened(IMenuOpenedArgs args) { if (!_pluginInterface.UiBuilder.ShouldModifyUi) return; if (args.AddonName != null) return; if (args.Target is not MenuTargetDefault target) return; if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0) return; IPlayerCharacter? targetData = GetPlayerFromObjectTable(target); if (targetData == null || targetData.Address == nint.Zero) return; //Check if user is directly paired or is own. if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus) return; var snapshot = _pairUiService.GetSnapshot(); var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue && (ulong)p.PlayerCharacterId == target.TargetObjectId); if (pair is not null) { pair.AddContextMenu(args); return; } //Check if user is directly paired or is own. if (VisibleUserIds.Contains(target.TargetObjectId) || (_clientState.LocalPlayer?.GameObjectId ?? 0) == target.TargetObjectId) return; if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing) return; var world = GetWorld(target.TargetHomeWorld.RowId); if (!IsWorldValid(world)) return; string? targetHashedCid = null; if (_broadcastService.IsBroadcasting) { targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); } if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid)) { var hashedCid = targetHashedCid; args.AddMenuItem(new MenuItem { Name = "Open Lightless Profile", PrefixChar = 'L', UseDefaultPrefix = false, PrefixColor = 708, OnClicked = async _ => await HandleLightfinderProfileSelection(hashedCid!).ConfigureAwait(false) }); } args.AddMenuItem(new MenuItem { Name = "Send Direct Pair Request", PrefixChar = 'L', UseDefaultPrefix = false, PrefixColor = 708, OnClicked = async _ => await HandleSelection(args).ConfigureAwait(false) }); } private HashSet VisibleUserIds => _pairUiService.GetSnapshot().PairsByUid.Values .Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue) .Select(p => (ulong)p.PlayerCharacterId) .ToHashSet(); private async Task HandleSelection(IMenuArgs args) { if (args.Target is not MenuTargetDefault target) return; var world = GetWorld(target.TargetHomeWorld.RowId); if (!IsWorldValid(world)) return; try { IPlayerCharacter? targetData = GetPlayerFromObjectTable(target); if (targetData == null || targetData.Address == nint.Zero) { _logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name); return; } var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); _logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid); await _apiController.TryPairWithContentId(receiverCid).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(receiverCid)) { _pairRequestService.RemoveRequest(receiverCid); } } catch (Exception ex) { _logger.LogError(ex, "Error sending pair request."); } } private async Task HandleLightfinderProfileSelection(string hashedCid) { if (string.IsNullOrWhiteSpace(hashedCid)) return; if (!_broadcastService.IsBroadcasting) { Notify("Lightfinder inactive", "Enable Lightfinder to open broadcaster profiles.", NotificationType.Warning, 6); return; } if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry) || !entry.IsBroadcasting || entry.ExpiryTime <= DateTime.UtcNow) { Notify("Broadcaster unavailable", "That player is not currently using Lightfinder.", NotificationType.Info, 5); return; } var result = await _lightlessProfileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false); if (result == null) { Notify("Profile unavailable", "Unable to load Lightless profile for that player.", NotificationType.Error, 6); return; } _mediator.Publish(new OpenLightfinderProfileMessage(result.Value.User, result.Value.ProfileData, hashedCid)); } private void Notify(string title, string message, NotificationType type, double durationSeconds) { _mediator.Publish(new NotificationMessage(title, message, type, TimeSpan.FromSeconds(durationSeconds))); } private bool CanOpenLightfinderProfile(string hashedCid) { if (!_broadcastService.IsBroadcasting) return false; if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry)) return false; return entry.IsBroadcasting && entry.ExpiryTime > DateTime.UtcNow; } private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target) { return _objectTable .OfType() .FirstOrDefault(p => string.Equals(p.Name.TextValue, target.TargetName, StringComparison.OrdinalIgnoreCase) && p.HomeWorld.RowId == target.TargetHomeWorld.RowId); } private World GetWorld(uint worldId) { var sheet = _gameData.GetExcelSheet()!; var luminaWorlds = sheet.Where(x => { var dc = x.DataCenter.ValueNullable; var name = x.Name.ExtractText(); var internalName = x.InternalName.ExtractText(); if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText())) return false; if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName)) return false; if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal)) return false; return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name); }); return luminaWorlds.FirstOrDefault(x => x.RowId == worldId); } private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter); private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF; public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId)); public static bool IsWorldValid(World world) { var name = world.Name.ToString(); return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]); } }