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; using LightlessSync.UI; 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; private const int _lightlessPrefixColor = 708; 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) { var addonName = args.AddonName; _logger.LogTrace("Context menu addon name: {AddonName}", addonName); return; } if (args.Target is not MenuTargetDefault target) { _logger.LogTrace("Context menu target is not MenuTargetDefault."); return; } _logger.LogTrace("Context menu opened for target: {Target}", target.TargetName ?? "null"); if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0) { _logger.LogTrace("Context menu target has invalid data: Name='{TargetName}', ObjectId={TargetObjectId}, HomeWorldId={TargetHomeWorldId}", target.TargetName, target.TargetObjectId, target.TargetHomeWorld.RowId); return; } IPlayerCharacter? targetData = GetPlayerFromObjectTable(target); if (targetData == null || targetData.Address == nint.Zero || _objectTable.LocalPlayer == null) { _logger.LogTrace("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.RowId); return; } var snapshot = _pairUiService.GetSnapshot(); var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue && p.PlayerCharacterId == target.TargetObjectId); if (pair is not null) { _logger.LogTrace("Target player {TargetName}@{World} is already paired, adding existing pair context menu.", target.TargetName, target.TargetHomeWorld.RowId); pair.AddContextMenu(args); if (!pair.IsDirectlyPaired) { _logger.LogTrace("Target player {TargetName}@{World} is not directly paired, add direct pair menu item", target.TargetName, target.TargetHomeWorld.RowId); AddDirectPairMenuItem(args); } return; } _logger.LogTrace("Target player {TargetName}@{World} is not paired, adding direct pair request context menu.", target.TargetName, target.TargetHomeWorld.RowId); //Check if user is directly paired or is own. if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _objectTable.LocalPlayer?.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus) { _logger.LogTrace("Target player {TargetName}@{World} is already paired or is self, or right-click menus are disabled.", target.TargetName, target.TargetHomeWorld.RowId); return; } if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing) { _logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId); return; } var world = GetWorld(target.TargetHomeWorld.RowId); if (!IsWorldValid(world)) { _logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId); return; } string? targetHashedCid = null; if (_broadcastService.IsBroadcasting) { targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); } if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid)) { var hashedCid = targetHashedCid; UiSharedService.AddContextMenuItem(args, name: "Open Lightless Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => HandleLightfinderProfileSelection(hashedCid)); } AddDirectPairMenuItem(args); } private void AddDirectPairMenuItem(IMenuOpenedArgs args) { UiSharedService.AddContextMenuItem( args, name: "Send Direct Pair Request", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => HandleSelection(args)); } private HashSet VisibleUserIds => [.. _pairUiService.GetSnapshot().PairsByUid.Values .Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue) .Select(p => (ulong)p.PlayerCharacterId)]; 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().GetBlake3Hash(); 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 static bool IsWorldValid(World world) { var name = world.Name.ToString(); return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]); } }