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/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 9163db7..9e11402 100644
--- a/HSRTools/Configuration/HSRToolsConfiguration.cs
+++ b/HSRTools/Configuration/HSRToolsConfiguration.cs
@@ -45,4 +45,36 @@ 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;
+
+ ///
+ /// 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/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..b8626aa 100644
--- a/HSRTools/Plugin.cs
+++ b/HSRTools/Plugin.cs
@@ -15,6 +15,8 @@ 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;
private HSRToolsConfiguration _config;
@@ -32,24 +34,44 @@ 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,
+ _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();
}
public void Dispose()
{
+ PluginServices.Framework.Update -= _friendFcCacheService.OnFrameworkUpdate;
PluginServices.CommandManager.RemoveHandler("/hsr");
+ _friendFcCacheService.Stop();
_chatMonitorService.Stop();
+ _autoAcceptPartyService.Stop();
_windowSystem.RemoveAllWindows();
SaveConfig(_configDir, _config);
}
@@ -57,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
new file mode 100644
index 0000000..422c3a2
--- /dev/null
+++ b/HSRTools/Services/AutoAcceptPartyService.cs
@@ -0,0 +1,229 @@
+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
+{
+ // 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, 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, 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, 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 && (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($"[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($"[AutoAccept] Failed to auto-accept party invite from {inviterName}.");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, $"[AutoAccept] Error auto-accepting party invite from {inviterName}");
+ }
+ }
+ }
+
+ 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 (Exception ex)
+ {
+ if (_config.Debug)
+ _log.Error(ex, "[AutoAccept] IsFriend exception");
+ return false;
+ }
+ }
+
+ 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 (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 679604b..37f6f9f 100644
--- a/HSRTools/Services/PluginServices.cs
+++ b/HSRTools/Services/PluginServices.cs
@@ -13,4 +13,7 @@ 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!;
+ [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 10fb357..208570a 100644
--- a/HSRTools/UI/ConfigWindow.cs
+++ b/HSRTools/UI/ConfigWindow.cs
@@ -15,6 +15,11 @@ public sealed class ConfigWindow : Window
private bool _monitorTell;
private bool _enabled;
private bool _debug;
+ private bool _autoAcceptEnabled;
+ private bool _autoAcceptFromFriends;
+ private bool _autoAcceptFromFreeCompany;
+ private bool _autoAcceptScanAddons;
+ private bool _cacheFriendsAndFcOnLogin;
public ConfigWindow(HSRToolsConfiguration config)
: base("HSRTools Configuration", ImGuiWindowFlags.AlwaysAutoResize)
@@ -33,6 +38,11 @@ public sealed class ConfigWindow : Window
_monitorTell = _config.MonitorTell;
_enabled = _config.Enabled;
_debug = _config.Debug;
+ _autoAcceptEnabled = _config.AutoAcceptEnabled;
+ _autoAcceptFromFriends = _config.AutoAcceptFromFriends;
+ _autoAcceptFromFreeCompany = _config.AutoAcceptFromFreeCompany;
+ _autoAcceptScanAddons = _config.AutoAcceptScanAddons;
+ _cacheFriendsAndFcOnLogin = _config.CacheFriendsAndFcOnLogin;
}
public override void Draw()
@@ -47,6 +57,11 @@ public sealed class ConfigWindow : Window
_config.MonitorTell = true;
_config.Enabled = true;
_config.Debug = false;
+ _config.AutoAcceptEnabled = false;
+ _config.AutoAcceptFromFriends = true;
+ _config.AutoAcceptFromFreeCompany = true;
+ _config.AutoAcceptScanAddons = false;
+ _config.CacheFriendsAndFcOnLogin = true;
SyncFromConfig();
}
@@ -78,5 +93,27 @@ 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;
+ }
+ 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();
}
}
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