From 4dcea5d7de12ef2ff7a93d91ff5d93cb87a88a7d Mon Sep 17 00:00:00 2001 From: jorg Date: Fri, 20 Feb 2026 20:40:44 -0600 Subject: [PATCH 1/2] Add auto-accept party invites from friends and FC members - New AutoAcceptPartyService uses IAddonLifecycle to detect party invite popup - Checks inviter against InfoProxyFriendList and InfoProxyFreeCompanyMember - Config options: AutoAcceptEnabled, AutoAcceptFromFriends, AutoAcceptFromFreeCompany - Bump version to 1.0.4, update CHANGELOG and README Co-authored-by: Cursor --- CHANGELOG.md | 6 + .../Configuration/HSRToolsConfiguration.cs | 18 +++ HSRTools/HSRTools.csproj | 2 +- HSRTools/HSRTools.json | 4 +- HSRTools/Plugin.cs | 9 ++ HSRTools/Services/AutoAcceptPartyService.cs | 134 ++++++++++++++++++ HSRTools/Services/PluginServices.cs | 1 + HSRTools/UI/ConfigWindow.cs | 23 +++ README.md | 7 +- 9 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 HSRTools/Services/AutoAcceptPartyService.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index de33be6..8cd46b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.4] - 2025-02-20 + +### Added + +- **Auto-accept party invites** – Option to automatically accept party invites when the inviter is on your friend list or is a member of your Free Company. Configurable toggles for friends and FC members. + ## [1.0.3] - 2025-02-03 ### Added diff --git a/HSRTools/Configuration/HSRToolsConfiguration.cs b/HSRTools/Configuration/HSRToolsConfiguration.cs index 9163db7..b3da551 100644 --- a/HSRTools/Configuration/HSRToolsConfiguration.cs +++ b/HSRTools/Configuration/HSRToolsConfiguration.cs @@ -45,4 +45,22 @@ public class HSRToolsConfiguration /// Use this to diagnose cross-world invite issues. /// public bool Debug { get; set; } = false; + + // --- Auto-accept party invites --- + + /// + /// When true, automatically accept party invites from friends and/or Free Company members + /// (based on the options below). + /// + public bool AutoAcceptEnabled { get; set; } = false; + + /// + /// When true and AutoAcceptEnabled, accept party invites from players on your friend list. + /// + public bool AutoAcceptFromFriends { get; set; } = true; + + /// + /// When true and AutoAcceptEnabled, accept party invites from members of your Free Company. + /// + public bool AutoAcceptFromFreeCompany { get; set; } = true; } diff --git a/HSRTools/HSRTools.csproj b/HSRTools/HSRTools.csproj index 792d55c..3f15c14 100644 --- a/HSRTools/HSRTools.csproj +++ b/HSRTools/HSRTools.csproj @@ -1,7 +1,7 @@ true - 1.0.3.0 + 1.0.4.0 Knack117 HSRTools HSRTools diff --git a/HSRTools/HSRTools.json b/HSRTools/HSRTools.json index 3f3fba9..c6d1401 100644 --- a/HSRTools/HSRTools.json +++ b/HSRTools/HSRTools.json @@ -1,8 +1,8 @@ { "Author": "Knack117", "Name": "HSRTools", - "Punchline": "Auto-invite to party when a trigger word is said in FC, LS, CWLS, or tells.", - "Description": "Detects a user-defined trigger word in Free Company, Link Shell, Cross-World Link Shell, and tell chat, then invites the sender to your party. Works for same-world and cross-world (CWLS).", + "Punchline": "Auto-invite and auto-accept party invites from friends/FC.", + "Description": "Detects a trigger word in FC, LS, CWLS, and tells to auto-invite; optionally auto-accepts party invites from friends and FC members.", "RepoUrl": "https://github.com/Knack117/HSRTools", "Tags": [ "chat", "party", "invite", "automation" ], "AcceptsFeedback": true diff --git a/HSRTools/Plugin.cs b/HSRTools/Plugin.cs index d38f515..a6cc6e2 100644 --- a/HSRTools/Plugin.cs +++ b/HSRTools/Plugin.cs @@ -15,6 +15,7 @@ public sealed class HSRToolsPlugin : IDalamudPlugin private readonly string _configDir; private readonly ChatMonitorService _chatMonitorService; + private readonly AutoAcceptPartyService _autoAcceptPartyService; private readonly ConfigWindow _configWindow; private readonly WindowSystem _windowSystem; private HSRToolsConfiguration _config; @@ -32,6 +33,11 @@ public sealed class HSRToolsPlugin : IDalamudPlugin PluginServices.PluginLog, _config); + _autoAcceptPartyService = new AutoAcceptPartyService( + PluginServices.AddonLifecycle, + PluginServices.PluginLog, + _config); + _configWindow = new ConfigWindow(_config); _windowSystem = new WindowSystem("HSRTools"); _windowSystem.AddWindow(_configWindow); @@ -44,12 +50,15 @@ public sealed class HSRToolsPlugin : IDalamudPlugin }); _chatMonitorService.SetConfiguration(_config); _chatMonitorService.Start(); + _autoAcceptPartyService.SetConfiguration(_config); + _autoAcceptPartyService.Start(); } public void Dispose() { PluginServices.CommandManager.RemoveHandler("/hsr"); _chatMonitorService.Stop(); + _autoAcceptPartyService.Stop(); _windowSystem.RemoveAllWindows(); SaveConfig(_configDir, _config); } diff --git a/HSRTools/Services/AutoAcceptPartyService.cs b/HSRTools/Services/AutoAcceptPartyService.cs new file mode 100644 index 0000000..9fc82d9 --- /dev/null +++ b/HSRTools/Services/AutoAcceptPartyService.cs @@ -0,0 +1,134 @@ +using System; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Info; +using HSRTools.Configuration; + +namespace HSRTools.Services; + +/// +/// Automatically accepts party invites when the inviter is a friend or Free Company member. +/// Uses IAddonLifecycle to detect when the party invite addon opens, then checks the +/// inviter against the friend list and FC roster before accepting. +/// +public sealed class AutoAcceptPartyService +{ + private const string PartyInviteAddonName = "PartyInvite"; + + private readonly IAddonLifecycle _addonLifecycle; + private readonly IPluginLog _log; + private HSRToolsConfiguration _config; + + public AutoAcceptPartyService(IAddonLifecycle addonLifecycle, IPluginLog log, HSRToolsConfiguration config) + { + _addonLifecycle = addonLifecycle; + _log = log; + _config = config; + } + + public void SetConfiguration(HSRToolsConfiguration config) + { + _config = config; + } + + public void Start() + { + _addonLifecycle.RegisterListener(AddonEvent.PreSetup, PartyInviteAddonName, OnPartyInviteAddon); + } + + public void Stop() + { + _addonLifecycle.UnregisterListener(AddonEvent.PreSetup, PartyInviteAddonName, OnPartyInviteAddon); + } + + private void OnPartyInviteAddon(AddonEvent eventType, AddonArgs args) + { + if (!_config.AutoAcceptEnabled) + return; + + if (!_config.AutoAcceptFromFriends && !_config.AutoAcceptFromFreeCompany) + return; + + unsafe + { + var agent = AgentPartyInvite.Instance(); + if (agent == null) + return; + + var proxy = agent->InfoProxyPartyInvite; + if (proxy == null) + return; + + var inviterName = proxy->InviterName.ToString(); + if (string.IsNullOrWhiteSpace(inviterName)) + return; + + var inviterWorldId = proxy->InviterWorldId; + + var isFriend = _config.AutoAcceptFromFriends && IsFriend(inviterName, inviterWorldId); + var isFcMember = _config.AutoAcceptFromFreeCompany && IsFreeCompanyMember(inviterName, inviterWorldId); + + if (!isFriend && !isFcMember) + return; + + var reason = isFriend && isFcMember ? "friend and FC member" : isFriend ? "friend" : "FC member"; + _log.Info($"Auto-accepting party invite from {inviterName} ({reason})."); + + try + { + // RespondToInvitation is on InfoProxyInvitedList (parent of InfoProxyPartyInvite) + var accepted = ((InfoProxyInvitedList*)proxy)->RespondToInvitation(inviterName, true); + if (!accepted) + _log.Warning($"Failed to auto-accept party invite from {inviterName}."); + } + catch (Exception ex) + { + _log.Error(ex, $"Error auto-accepting party invite from {inviterName}"); + } + } + } + + private static unsafe bool IsFriend(string characterName, ushort worldId) + { + try + { + var infoModule = InfoModule.Instance(); + if (infoModule == null) + return false; + + var proxy = (InfoProxyCommonList*)infoModule->GetInfoProxyById(InfoProxyId.FriendList); + if (proxy == null) + return false; + + var entry = proxy->GetEntryByName(characterName, worldId); + return entry != null; + } + catch + { + return false; + } + } + + private static unsafe bool IsFreeCompanyMember(string characterName, ushort worldId) + { + try + { + var infoModule = InfoModule.Instance(); + if (infoModule == null) + return false; + + var proxy = (InfoProxyCommonList*)infoModule->GetInfoProxyById(InfoProxyId.FreeCompanyMember); + if (proxy == null) + return false; + + var entry = proxy->GetEntryByName(characterName, worldId); + return entry != null; + } + catch + { + return false; + } + } +} diff --git a/HSRTools/Services/PluginServices.cs b/HSRTools/Services/PluginServices.cs index 679604b..dc4bd83 100644 --- a/HSRTools/Services/PluginServices.cs +++ b/HSRTools/Services/PluginServices.cs @@ -13,4 +13,5 @@ public sealed class PluginServices [PluginService] public static IDalamudPluginInterface PluginInterface { get; private set; } = null!; [PluginService] public static IToastGui ToastGui { get; private set; } = null!; [PluginService] public static ICommandManager CommandManager { get; private set; } = null!; + [PluginService] public static IAddonLifecycle AddonLifecycle { get; private set; } = null!; } diff --git a/HSRTools/UI/ConfigWindow.cs b/HSRTools/UI/ConfigWindow.cs index 10fb357..527302d 100644 --- a/HSRTools/UI/ConfigWindow.cs +++ b/HSRTools/UI/ConfigWindow.cs @@ -15,6 +15,9 @@ public sealed class ConfigWindow : Window private bool _monitorTell; private bool _enabled; private bool _debug; + private bool _autoAcceptEnabled; + private bool _autoAcceptFromFriends; + private bool _autoAcceptFromFreeCompany; public ConfigWindow(HSRToolsConfiguration config) : base("HSRTools Configuration", ImGuiWindowFlags.AlwaysAutoResize) @@ -33,6 +36,9 @@ public sealed class ConfigWindow : Window _monitorTell = _config.MonitorTell; _enabled = _config.Enabled; _debug = _config.Debug; + _autoAcceptEnabled = _config.AutoAcceptEnabled; + _autoAcceptFromFriends = _config.AutoAcceptFromFriends; + _autoAcceptFromFreeCompany = _config.AutoAcceptFromFreeCompany; } public override void Draw() @@ -47,6 +53,9 @@ public sealed class ConfigWindow : Window _config.MonitorTell = true; _config.Enabled = true; _config.Debug = false; + _config.AutoAcceptEnabled = false; + _config.AutoAcceptFromFriends = true; + _config.AutoAcceptFromFreeCompany = true; SyncFromConfig(); } @@ -78,5 +87,19 @@ public sealed class ConfigWindow : Window ImGui.Separator(); if (ImGui.Checkbox("Debug logging (diagnose cross-world invite)", ref _debug)) _config.Debug = _debug; + + ImGui.Separator(); + ImGui.Text("Auto-accept party invites:"); + ImGui.Indent(); + if (ImGui.Checkbox("Auto-accept from friends and FC members", ref _autoAcceptEnabled)) + _config.AutoAcceptEnabled = _autoAcceptEnabled; + if (_autoAcceptEnabled) + { + if (ImGui.Checkbox(" From friends", ref _autoAcceptFromFriends)) + _config.AutoAcceptFromFriends = _autoAcceptFromFriends; + if (ImGui.Checkbox(" From Free Company members", ref _autoAcceptFromFreeCompany)) + _config.AutoAcceptFromFreeCompany = _autoAcceptFromFreeCompany; + } + ImGui.Unindent(); } } diff --git a/README.md b/README.md index cd6649a..7216497 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A [Dalamud](https://github.com/goatcorp/Dalamud) plugin for FFXIV (XIVLauncher) ## Features - **Configurable trigger text** – Set any word or phrase (e.g. `inv`, `invite`, `party`). When someone types it in a monitored channel, they are invited to your party. +- **Auto-accept party invites** – Optionally automatically accept party invites when the inviter is on your friend list or is a member of your Free Company. - **Channel selection** – Choose which channels to monitor: - Free Company - Link Shell (1–8) @@ -21,6 +22,10 @@ A [Dalamud](https://github.com/goatcorp/Dalamud) plugin for FFXIV (XIVLauncher) For **tells**, the game provides the sender’s content ID and world, so invites work reliably. For **FC, LS, and CWLS**, the plugin uses the sender’s content ID when available (e.g. from the last message in CWLS), so cross-world invites work for CWLS and same-world for FC/LS. +### Auto-accept party invites + +When enabled, the plugin detects incoming party invites and automatically accepts them if the inviter is on your friend list or is a member of your Free Company. You can toggle friends and FC members independently. The friend list and FC roster must have been opened in-game at least once for the plugin to recognize members. + ## Building 1. Open `HSRTools.sln` in Visual Studio or use the command line. @@ -35,7 +40,7 @@ For **tells**, the game provides the sender’s content ID and world, so invites ## Configuration - Open the plugin config from the Dalamud plugin list (right-click HSRTools → **Settings**). -- Set the trigger text, enable/disable the plugin, choose channels, and set case sensitivity. +- Set the trigger text, enable/disable the plugin, choose channels, set case sensitivity, and configure auto-accept (friends/FC). - Settings are saved when you close the game or disable the plugin. ## Requirements -- 2.52.0 From b1f01d279479e14798ea0c6bb8bdb0efa9328703 Mon Sep 17 00:00:00 2001 From: jorg Date: Sat, 21 Feb 2026 12:48:29 -0600 Subject: [PATCH 2/2] Auto-accept: debug logging, scan mode, friend/FC cache on login - Add debug logging (gated by Debug) and scan mode to find party-invite addon - Listen for PartyInvite and SelectYesno addons - Cache friends and FC members on login: open Friends/FC addons briefly, scrape names, close; persist cache to HSRToolsFriendFcCache.json - Auto-accept checks cache first, then live proxy (works without opening UIs) - Config: CacheFriendsAndFcOnLogin, AutoAcceptScanAddons - FC: RequestData() + longer wait; clearer log when 0 FC members Co-authored-by: Cursor --- HSRTools/Configuration/FriendFcCache.cs | 38 ++ .../Configuration/HSRToolsConfiguration.cs | 14 + HSRTools/Plugin.cs | 20 +- HSRTools/Services/AutoAcceptPartyService.cs | 121 ++++++- HSRTools/Services/FriendFcCacheService.cs | 326 ++++++++++++++++++ HSRTools/Services/PluginServices.cs | 2 + HSRTools/UI/ConfigWindow.cs | 14 + 7 files changed, 521 insertions(+), 14 deletions(-) create mode 100644 HSRTools/Configuration/FriendFcCache.cs create mode 100644 HSRTools/Services/FriendFcCacheService.cs diff --git a/HSRTools/Configuration/FriendFcCache.cs b/HSRTools/Configuration/FriendFcCache.cs new file mode 100644 index 0000000..93ad8da --- /dev/null +++ b/HSRTools/Configuration/FriendFcCache.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace HSRTools.Configuration; + +/// +/// Cached list of (Name, WorldId) for friends and FC members. +/// Persisted to disk so we don't need to open the in-game windows every time. +/// +public class FriendFcCache +{ + /// Friend list: character name + home world ID. + public List Friends { get; set; } = new(); + + /// Free Company members: character name + home world ID. + public List FreeCompanyMembers { get; set; } = new(); + + /// When the cache was last refreshed (UTC). + public long LastUpdatedUtcTicks { get; set; } + + public bool IsInFriends(string name, ushort worldId) + { + if (Friends == null || Friends.Count == 0) return false; + return Friends.Exists(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && p.WorldId == worldId); + } + + public bool IsInFreeCompany(string name, ushort worldId) + { + if (FreeCompanyMembers == null || FreeCompanyMembers.Count == 0) return false; + return FreeCompanyMembers.Exists(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && p.WorldId == worldId); + } +} + +public class CachedPlayer +{ + public string Name { get; set; } = string.Empty; + public ushort WorldId { get; set; } +} diff --git a/HSRTools/Configuration/HSRToolsConfiguration.cs b/HSRTools/Configuration/HSRToolsConfiguration.cs index b3da551..9e11402 100644 --- a/HSRTools/Configuration/HSRToolsConfiguration.cs +++ b/HSRTools/Configuration/HSRToolsConfiguration.cs @@ -63,4 +63,18 @@ public class HSRToolsConfiguration /// When true and AutoAcceptEnabled, accept party invites from members of your Free Company. /// public bool AutoAcceptFromFreeCompany { get; set; } = true; + + /// + /// When true, logs every addon that opens. Use this to find the party invite addon name: + /// enable it, get a party invite, then check the log. Disable after finding the name. + /// Very noisy - only use briefly. + /// + public bool AutoAcceptScanAddons { get; set; } = false; + + /// + /// When true, on each login the plugin will briefly open the Friends and Free Company + /// windows to cache names, then close them. The cache is saved so auto-accept works + /// without you opening those windows. + /// + public bool CacheFriendsAndFcOnLogin { get; set; } = true; } diff --git a/HSRTools/Plugin.cs b/HSRTools/Plugin.cs index a6cc6e2..b8626aa 100644 --- a/HSRTools/Plugin.cs +++ b/HSRTools/Plugin.cs @@ -15,6 +15,7 @@ public sealed class HSRToolsPlugin : IDalamudPlugin private readonly string _configDir; private readonly ChatMonitorService _chatMonitorService; + private readonly FriendFcCacheService _friendFcCacheService; private readonly AutoAcceptPartyService _autoAcceptPartyService; private readonly ConfigWindow _configWindow; private readonly WindowSystem _windowSystem; @@ -33,22 +34,32 @@ public sealed class HSRToolsPlugin : IDalamudPlugin PluginServices.PluginLog, _config); + _friendFcCacheService = new FriendFcCacheService( + _configDir, + PluginServices.PluginLog, + PluginServices.Framework, + PluginServices.ClientState, + _config); + _autoAcceptPartyService = new AutoAcceptPartyService( PluginServices.AddonLifecycle, PluginServices.PluginLog, - _config); + _config, + _friendFcCacheService); _configWindow = new ConfigWindow(_config); _windowSystem = new WindowSystem("HSRTools"); _windowSystem.AddWindow(_configWindow); PluginServices.PluginInterface.UiBuilder.Draw += OnDraw; + PluginServices.Framework.Update += _friendFcCacheService.OnFrameworkUpdate; PluginServices.PluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi; PluginServices.CommandManager.AddHandler("/hsr", new CommandInfo(OnHsrCommand) { HelpMessage = "Open HSRTools settings.", }); _chatMonitorService.SetConfiguration(_config); + _friendFcCacheService.Start(); _chatMonitorService.Start(); _autoAcceptPartyService.SetConfiguration(_config); _autoAcceptPartyService.Start(); @@ -56,7 +67,9 @@ public sealed class HSRToolsPlugin : IDalamudPlugin public void Dispose() { + PluginServices.Framework.Update -= _friendFcCacheService.OnFrameworkUpdate; PluginServices.CommandManager.RemoveHandler("/hsr"); + _friendFcCacheService.Stop(); _chatMonitorService.Stop(); _autoAcceptPartyService.Stop(); _windowSystem.RemoveAllWindows(); @@ -66,6 +79,11 @@ public sealed class HSRToolsPlugin : IDalamudPlugin private void OnDraw() { _windowSystem.Draw(); + if (_configWindow.IsOpen) + { + _autoAcceptPartyService.SetConfiguration(_config); + _friendFcCacheService.SetConfiguration(_config); + } } private void OpenConfigUi() diff --git a/HSRTools/Services/AutoAcceptPartyService.cs b/HSRTools/Services/AutoAcceptPartyService.cs index 9fc82d9..422c3a2 100644 --- a/HSRTools/Services/AutoAcceptPartyService.cs +++ b/HSRTools/Services/AutoAcceptPartyService.cs @@ -15,119 +15,214 @@ namespace HSRTools.Services; /// public sealed class AutoAcceptPartyService { - private const string PartyInviteAddonName = "PartyInvite"; + // Party invite may use PartyInvite or SelectYesno (common confirmation dialog) - listen to both + private static readonly string[] PartyInviteAddonNames = ["PartyInvite", "SelectYesno"]; private readonly IAddonLifecycle _addonLifecycle; private readonly IPluginLog _log; + private readonly FriendFcCacheService _cacheService; private HSRToolsConfiguration _config; + private bool _scanListenerRegistered; - public AutoAcceptPartyService(IAddonLifecycle addonLifecycle, IPluginLog log, HSRToolsConfiguration config) + public AutoAcceptPartyService(IAddonLifecycle addonLifecycle, IPluginLog log, HSRToolsConfiguration config, FriendFcCacheService cacheService) { _addonLifecycle = addonLifecycle; _log = log; _config = config; + _cacheService = cacheService; } public void SetConfiguration(HSRToolsConfiguration config) { + var wasScanning = _scanListenerRegistered; _config = config; + if (wasScanning && !_config.AutoAcceptScanAddons) + UnregisterScanListener(); + else if (!wasScanning && _config.AutoAcceptScanAddons) + RegisterScanListener(); } public void Start() { - _addonLifecycle.RegisterListener(AddonEvent.PreSetup, PartyInviteAddonName, OnPartyInviteAddon); + _addonLifecycle.RegisterListener(AddonEvent.PreSetup, PartyInviteAddonNames, OnPartyInviteAddon); + _log.Info($"[AutoAccept] Listening for party invites (addons: {string.Join(", ", PartyInviteAddonNames)}). Enable Debug for detailed logs."); + if (_config.AutoAcceptScanAddons) + RegisterScanListener(); } public void Stop() { - _addonLifecycle.UnregisterListener(AddonEvent.PreSetup, PartyInviteAddonName, OnPartyInviteAddon); + _addonLifecycle.UnregisterListener(AddonEvent.PreSetup, PartyInviteAddonNames, OnPartyInviteAddon); + UnregisterScanListener(); + } + + private void RegisterScanListener() + { + if (_scanListenerRegistered) return; + _addonLifecycle.RegisterListener(AddonEvent.PreSetup, OnAnyAddonScan); + _scanListenerRegistered = true; + _log.Info("[AutoAccept] SCAN MODE: Logging every addon that opens. Get a party invite and check the log for the addon name."); + } + + private void UnregisterScanListener() + { + if (!_scanListenerRegistered) return; + _addonLifecycle.UnregisterListener(AddonEvent.PreSetup, OnAnyAddonScan); + _scanListenerRegistered = false; + } + + private void OnAnyAddonScan(AddonEvent eventType, AddonArgs args) + { + if (!_config.AutoAcceptScanAddons) return; + _log.Info($"[AutoAccept] SCAN: Addon opened: '{args.AddonName}'"); } private void OnPartyInviteAddon(AddonEvent eventType, AddonArgs args) { + if (_config.Debug) + _log.Info("[AutoAccept] Party invite addon triggered."); + if (!_config.AutoAcceptEnabled) + { + if (_config.Debug) + _log.Info("[AutoAccept] Skipped: Auto-accept is disabled."); return; + } if (!_config.AutoAcceptFromFriends && !_config.AutoAcceptFromFreeCompany) + { + if (_config.Debug) + _log.Info("[AutoAccept] Skipped: Both friends and FC options are disabled."); return; + } unsafe { var agent = AgentPartyInvite.Instance(); if (agent == null) + { + if (_config.Debug) + _log.Warning("[AutoAccept] AgentPartyInvite.Instance() is null."); return; + } var proxy = agent->InfoProxyPartyInvite; if (proxy == null) + { + if (_config.Debug) + _log.Warning("[AutoAccept] InfoProxyPartyInvite is null."); return; + } var inviterName = proxy->InviterName.ToString(); if (string.IsNullOrWhiteSpace(inviterName)) + { + if (_config.Debug) + _log.Warning("[AutoAccept] Inviter name is null or empty."); return; + } var inviterWorldId = proxy->InviterWorldId; + if (_config.Debug) + _log.Info($"[AutoAccept] Inviter: '{inviterName}' (worldId={inviterWorldId})"); - var isFriend = _config.AutoAcceptFromFriends && IsFriend(inviterName, inviterWorldId); - var isFcMember = _config.AutoAcceptFromFreeCompany && IsFreeCompanyMember(inviterName, inviterWorldId); + var isFriend = _config.AutoAcceptFromFriends && (IsFriendCached(inviterName, inviterWorldId) || IsFriend(inviterName, inviterWorldId)); + var isFcMember = _config.AutoAcceptFromFreeCompany && (IsFreeCompanyMemberCached(inviterName, inviterWorldId) || IsFreeCompanyMember(inviterName, inviterWorldId)); + + if (_config.Debug) + _log.Info($"[AutoAccept] IsFriend={isFriend}, IsFcMember={isFcMember} (FriendsEnabled={_config.AutoAcceptFromFriends}, FCEnabled={_config.AutoAcceptFromFreeCompany})"); if (!isFriend && !isFcMember) + { + if (_config.Debug) + _log.Info("[AutoAccept] Skipped: Inviter is not a friend or FC member."); return; + } var reason = isFriend && isFcMember ? "friend and FC member" : isFriend ? "friend" : "FC member"; - _log.Info($"Auto-accepting party invite from {inviterName} ({reason})."); + _log.Info($"[AutoAccept] Auto-accepting party invite from {inviterName} ({reason})."); try { // RespondToInvitation is on InfoProxyInvitedList (parent of InfoProxyPartyInvite) var accepted = ((InfoProxyInvitedList*)proxy)->RespondToInvitation(inviterName, true); + if (_config.Debug) + _log.Info($"[AutoAccept] RespondToInvitation returned: {accepted}"); if (!accepted) - _log.Warning($"Failed to auto-accept party invite from {inviterName}."); + _log.Warning($"[AutoAccept] Failed to auto-accept party invite from {inviterName}."); } catch (Exception ex) { - _log.Error(ex, $"Error auto-accepting party invite from {inviterName}"); + _log.Error(ex, $"[AutoAccept] Error auto-accepting party invite from {inviterName}"); } } } - private static unsafe bool IsFriend(string characterName, ushort worldId) + private bool IsFriendCached(string characterName, ushort worldId) => _cacheService.IsInFriends(characterName, worldId); + private bool IsFreeCompanyMemberCached(string characterName, ushort worldId) => _cacheService.IsInFreeCompany(characterName, worldId); + + private unsafe bool IsFriend(string characterName, ushort worldId) { try { var infoModule = InfoModule.Instance(); if (infoModule == null) + { + if (_config.Debug) + _log.Warning("[AutoAccept] IsFriend: InfoModule.Instance() is null."); return false; + } var proxy = (InfoProxyCommonList*)infoModule->GetInfoProxyById(InfoProxyId.FriendList); if (proxy == null) + { + if (_config.Debug) + _log.Warning("[AutoAccept] IsFriend: FriendList proxy is null."); return false; + } var entry = proxy->GetEntryByName(characterName, worldId); + if (_config.Debug) + _log.Info($"[AutoAccept] IsFriend('{characterName}', worldId={worldId}): entry={(entry != null ? "found" : "null")}"); return entry != null; } - catch + catch (Exception ex) { + if (_config.Debug) + _log.Error(ex, "[AutoAccept] IsFriend exception"); return false; } } - private static unsafe bool IsFreeCompanyMember(string characterName, ushort worldId) + private unsafe bool IsFreeCompanyMember(string characterName, ushort worldId) { try { var infoModule = InfoModule.Instance(); if (infoModule == null) + { + if (_config.Debug) + _log.Warning("[AutoAccept] IsFreeCompanyMember: InfoModule.Instance() is null."); return false; + } var proxy = (InfoProxyCommonList*)infoModule->GetInfoProxyById(InfoProxyId.FreeCompanyMember); if (proxy == null) + { + if (_config.Debug) + _log.Warning("[AutoAccept] IsFreeCompanyMember: FreeCompanyMember proxy is null."); return false; + } var entry = proxy->GetEntryByName(characterName, worldId); + if (_config.Debug) + _log.Info($"[AutoAccept] IsFreeCompanyMember('{characterName}', worldId={worldId}): entry={(entry != null ? "found" : "null")}"); return entry != null; } - catch + catch (Exception ex) { + if (_config.Debug) + _log.Error(ex, "[AutoAccept] IsFreeCompanyMember exception"); return false; } } diff --git a/HSRTools/Services/FriendFcCacheService.cs b/HSRTools/Services/FriendFcCacheService.cs new file mode 100644 index 0000000..120fd10 --- /dev/null +++ b/HSRTools/Services/FriendFcCacheService.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Info; +using HSRTools.Configuration; + +namespace HSRTools.Services; + +/// +/// On login, opens the Friends and FC roster addons briefly to scrape names into a cache, +/// then closes them. The cache is persisted so auto-accept can use it without opening the UIs again. +/// +public sealed class FriendFcCacheService +{ + private const string CacheFileName = "HSRToolsFriendFcCache.json"; + private const int FramesToWaitAfterOpen = 120; // ~2 seconds for friend list + private const int FramesToWaitForFc = 240; // ~4 seconds – FC roster often loads slower + private const int NameOffsetInCharacterData = 0x32; + private const int NameMaxBytes = 32; + + private readonly string _configDir; + private readonly IPluginLog _log; + private readonly IFramework _framework; + private readonly IClientState _clientState; + private HSRToolsConfiguration _config; + private FriendFcCache _cache = new(); + private readonly object _cacheLock = new(); + private bool _loginHandled; + private int _scrapePhase; // 0=idle, 1=delay then open friends, 2=friends open (wait), 3=scrape friends & open FC, 4=FC open (wait), 5=scrape FC & done + private int _scrapeTicksLeft; + + public FriendFcCacheService( + string configDir, + IPluginLog log, + IFramework framework, + IClientState clientState, + HSRToolsConfiguration config) + { + _configDir = configDir; + _log = log; + _framework = framework; + _clientState = clientState; + _config = config; + } + + public void SetConfiguration(HSRToolsConfiguration config) + { + _config = config; + } + + public void Start() + { + LoadCache(); + _clientState.Login += OnLogin; + _clientState.Logout += OnLogout; + } + + public void Stop() + { + _clientState.Login -= OnLogin; + _clientState.Logout -= OnLogout; + _scrapePhase = 0; + } + + /// Returns a snapshot of the cache for reading. Prefer using IsInFriends/IsInFreeCompany. + public FriendFcCache GetCacheSnapshot() + { + lock (_cacheLock) + { + return new FriendFcCache + { + Friends = _cache.Friends == null ? new List() : new List(_cache.Friends), + FreeCompanyMembers = _cache.FreeCompanyMembers == null ? new List() : new List(_cache.FreeCompanyMembers), + LastUpdatedUtcTicks = _cache.LastUpdatedUtcTicks, + }; + } + } + + public bool IsInFriends(string name, ushort worldId) + { + lock (_cacheLock) { return _cache.IsInFriends(name, worldId); } + } + + public bool IsInFreeCompany(string name, ushort worldId) + { + lock (_cacheLock) { return _cache.IsInFreeCompany(name, worldId); } + } + + public bool HasCachedData() + { + lock (_cacheLock) + { + return (_cache.Friends?.Count ?? 0) > 0 || (_cache.FreeCompanyMembers?.Count ?? 0) > 0; + } + } + + private void OnLogin() + { + _loginHandled = false; + } + + private void OnLogout(int type, int code) + { + _loginHandled = false; + _scrapePhase = 0; + } + + /// Called each frame from plugin; runs delayed scrape after login. + public void OnFrameworkUpdate(IFramework framework) + { + if (_scrapePhase > 0) + { + TickScrapePhase(); + return; + } + if (!_config.CacheFriendsAndFcOnLogin || _loginHandled) + return; + if (!_clientState.IsLoggedIn) + return; + _loginHandled = true; + _log.Info("[Cache] Login detected. Will open Friends and FC windows to refresh cache."); + _scrapePhase = 1; + _scrapeTicksLeft = 60; + } + + private void TickScrapePhase() + { + if (_scrapeTicksLeft > 0) + { + _scrapeTicksLeft--; + return; + } + unsafe + { + switch (_scrapePhase) + { + case 1: + var friendAgent = AgentFriendlist.Instance(); + if (friendAgent == null) + { + _log.Warning("[Cache] AgentFriendlist not found."); + _scrapePhase = 3; + _scrapeTicksLeft = 0; + TickScrapePhase(); + return; + } + ((AgentInterface*)friendAgent)->ShowAddon(); + _scrapePhase = 2; + _scrapeTicksLeft = FramesToWaitAfterOpen; + break; + case 2: + if (AgentFriendlist.Instance() != null) + ((AgentInterface*)AgentFriendlist.Instance())->HideAddon(); + var friendList = ScrapeProxy(InfoProxyId.FriendList); + lock (_cacheLock) + { + _cache.Friends = friendList; + _log.Info($"[Cache] Cached {friendList.Count} friends."); + } + var fcAgent = AgentFreeCompany.Instance(); + if (fcAgent == null) + { + _log.Warning("[Cache] AgentFreeCompany not found."); + lock (_cacheLock) { _cache.LastUpdatedUtcTicks = DateTime.UtcNow.Ticks; } + SaveCache(); + _scrapePhase = 0; + return; + } + ((AgentInterface*)fcAgent)->ShowAddon(); + RequestFcMemberData(); + _scrapePhase = 4; + _scrapeTicksLeft = FramesToWaitForFc; + break; + case 3: + fcAgent = AgentFreeCompany.Instance(); + if (fcAgent == null) + { + _log.Warning("[Cache] AgentFreeCompany not found."); + SaveCache(); + _scrapePhase = 0; + return; + } + ((AgentInterface*)fcAgent)->ShowAddon(); + RequestFcMemberData(); + _scrapePhase = 4; + _scrapeTicksLeft = FramesToWaitForFc; + break; + case 4: + if (AgentFreeCompany.Instance() != null) + ((AgentInterface*)AgentFreeCompany.Instance())->HideAddon(); + var fcList = ScrapeProxy(InfoProxyId.FreeCompanyMember); + lock (_cacheLock) + { + _cache.FreeCompanyMembers = fcList; + _cache.LastUpdatedUtcTicks = DateTime.UtcNow.Ticks; + if (fcList.Count == 0) + _log.Info("[Cache] Cached 0 FC members (not in an FC, or roster not loaded yet). Cache refresh done."); + else + _log.Info($"[Cache] Cached {fcList.Count} FC members. Cache refresh done."); + } + SaveCache(); + _scrapePhase = 0; + break; + } + } + } + + /// Request the FC member list from the server; call after opening the FC addon. + private static unsafe void RequestFcMemberData() + { + try + { + var infoModule = InfoModule.Instance(); + if (infoModule == null) return; + var proxy = infoModule->GetInfoProxyById(InfoProxyId.FreeCompanyMember); + if (proxy == null) return; + proxy->RequestData(); + } + catch + { + // ignore + } + } + + private static unsafe List ScrapeProxy(InfoProxyId proxyId) + { + var result = new List(); + try + { + var infoModule = InfoModule.Instance(); + if (infoModule == null) return result; + + var proxy = (InfoProxyCommonList*)infoModule->GetInfoProxyById(proxyId); + if (proxy == null) return result; + + var count = ((InfoProxyInterface*)proxy)->GetEntryCount(); + if (count == 0 || proxy->CharData == null) return result; + + for (uint i = 0; i < count; i++) + { + var entry = proxy->GetEntry(i); + if (entry == null) continue; + var name = ReadCharacterName(entry); + if (string.IsNullOrWhiteSpace(name)) continue; + result.Add(new CachedPlayer { Name = name.Trim(), WorldId = entry->HomeWorld }); + } + } + catch + { + // ignore + } + return result; + } + + private static unsafe string ReadCharacterName(InfoProxyCommonList.CharacterData* entry) + { + if (entry == null) return string.Empty; + try + { + var ptr = (byte*)entry + NameOffsetInCharacterData; + var span = new ReadOnlySpan(ptr, NameMaxBytes); + var end = span.IndexOf((byte)0); + var len = end < 0 ? NameMaxBytes : end; + return Encoding.UTF8.GetString(span.Slice(0, len)); + } + catch + { + return string.Empty; + } + } + + private void LoadCache() + { + var path = Path.Combine(_configDir, CacheFileName); + if (!File.Exists(path)) return; + try + { + var json = File.ReadAllText(path); + var loaded = JsonSerializer.Deserialize(json); + if (loaded != null) + { + lock (_cacheLock) + { + _cache.Friends = loaded.Friends ?? new List(); + _cache.FreeCompanyMembers = loaded.FreeCompanyMembers ?? new List(); + _cache.LastUpdatedUtcTicks = loaded.LastUpdatedUtcTicks; + } + _log.Info($"[Cache] Loaded {_cache.Friends.Count} friends, {_cache.FreeCompanyMembers.Count} FC members from disk."); + } + } + catch (Exception ex) + { + _log.Warning(ex, "[Cache] Failed to load cache file."); + } + } + + private void SaveCache() + { + FriendFcCache snapshot; + lock (_cacheLock) + { + snapshot = new FriendFcCache + { + Friends = new List(_cache.Friends ?? new List()), + FreeCompanyMembers = new List(_cache.FreeCompanyMembers ?? new List()), + LastUpdatedUtcTicks = _cache.LastUpdatedUtcTicks, + }; + } + var path = Path.Combine(_configDir, CacheFileName); + try + { + Directory.CreateDirectory(_configDir); + var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + } + catch (Exception ex) + { + _log.Warning(ex, "[Cache] Failed to save cache file."); + } + } +} diff --git a/HSRTools/Services/PluginServices.cs b/HSRTools/Services/PluginServices.cs index dc4bd83..37f6f9f 100644 --- a/HSRTools/Services/PluginServices.cs +++ b/HSRTools/Services/PluginServices.cs @@ -14,4 +14,6 @@ public sealed class PluginServices [PluginService] public static IToastGui ToastGui { get; private set; } = null!; [PluginService] public static ICommandManager CommandManager { get; private set; } = null!; [PluginService] public static IAddonLifecycle AddonLifecycle { get; private set; } = null!; + [PluginService] public static IClientState ClientState { get; private set; } = null!; + [PluginService] public static IFramework Framework { get; private set; } = null!; } diff --git a/HSRTools/UI/ConfigWindow.cs b/HSRTools/UI/ConfigWindow.cs index 527302d..208570a 100644 --- a/HSRTools/UI/ConfigWindow.cs +++ b/HSRTools/UI/ConfigWindow.cs @@ -18,6 +18,8 @@ public sealed class ConfigWindow : Window private bool _autoAcceptEnabled; private bool _autoAcceptFromFriends; private bool _autoAcceptFromFreeCompany; + private bool _autoAcceptScanAddons; + private bool _cacheFriendsAndFcOnLogin; public ConfigWindow(HSRToolsConfiguration config) : base("HSRTools Configuration", ImGuiWindowFlags.AlwaysAutoResize) @@ -39,6 +41,8 @@ public sealed class ConfigWindow : Window _autoAcceptEnabled = _config.AutoAcceptEnabled; _autoAcceptFromFriends = _config.AutoAcceptFromFriends; _autoAcceptFromFreeCompany = _config.AutoAcceptFromFreeCompany; + _autoAcceptScanAddons = _config.AutoAcceptScanAddons; + _cacheFriendsAndFcOnLogin = _config.CacheFriendsAndFcOnLogin; } public override void Draw() @@ -56,6 +60,8 @@ public sealed class ConfigWindow : Window _config.AutoAcceptEnabled = false; _config.AutoAcceptFromFriends = true; _config.AutoAcceptFromFreeCompany = true; + _config.AutoAcceptScanAddons = false; + _config.CacheFriendsAndFcOnLogin = true; SyncFromConfig(); } @@ -100,6 +106,14 @@ public sealed class ConfigWindow : Window if (ImGui.Checkbox(" From Free Company members", ref _autoAcceptFromFreeCompany)) _config.AutoAcceptFromFreeCompany = _autoAcceptFromFreeCompany; } + if (ImGui.Checkbox("Scan mode: log all addons that open", ref _autoAcceptScanAddons)) + _config.AutoAcceptScanAddons = _autoAcceptScanAddons; + if (_autoAcceptScanAddons) + ImGui.TextColored(new System.Numerics.Vector4(1, 0.8f, 0, 1), " Enable, get a party invite, check log for addon name. Disable after."); + if (ImGui.Checkbox("Cache friends & FC on login", ref _cacheFriendsAndFcOnLogin)) + _config.CacheFriendsAndFcOnLogin = _cacheFriendsAndFcOnLogin; + if (_cacheFriendsAndFcOnLogin) + ImGui.TextColored(new System.Numerics.Vector4(0.7f, 0.7f, 0.7f, 1f), " Opens Friends and FC windows briefly on login to cache names, then closes them."); ImGui.Unindent(); } } -- 2.52.0