d3d8b0fa1a
Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
9.7 KiB
C#
259 lines
9.7 KiB
C#
using System;
|
|
using System.Linq;
|
|
using Dalamud.Game.Text;
|
|
using Dalamud.Game.Text.SeStringHandling;
|
|
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
|
using Dalamud.Plugin.Services;
|
|
using HSRTools.Configuration;
|
|
|
|
namespace HSRTools.Services;
|
|
|
|
/// <summary>
|
|
/// Monitors chat for the user-defined trigger and initiates party invites.
|
|
/// </summary>
|
|
public sealed class ChatMonitorService
|
|
{
|
|
private readonly IChatGui _chatGui;
|
|
private readonly PartyInviteService _partyInviteService;
|
|
private readonly IPluginLog _log;
|
|
private HSRToolsConfiguration _config;
|
|
|
|
/// <summary>When set, the next party-error message (e.g. from our fallback invite) will be suppressed.</summary>
|
|
private DateTime _suppressPartyErrorUntil = DateTime.MinValue;
|
|
|
|
private static readonly TimeSpan SuppressPartyErrorWindow = TimeSpan.FromSeconds(4);
|
|
|
|
private static readonly XivChatType[] LinkShellTypes =
|
|
[
|
|
XivChatType.Ls1, XivChatType.Ls2, XivChatType.Ls3, XivChatType.Ls4,
|
|
XivChatType.Ls5, XivChatType.Ls6, XivChatType.Ls7, XivChatType.Ls8,
|
|
];
|
|
|
|
private static readonly XivChatType[] CrossWorldLinkShellTypes =
|
|
[
|
|
XivChatType.CrossLinkShell1, XivChatType.CrossLinkShell2, XivChatType.CrossLinkShell3,
|
|
XivChatType.CrossLinkShell4, XivChatType.CrossLinkShell5, XivChatType.CrossLinkShell6,
|
|
XivChatType.CrossLinkShell7, XivChatType.CrossLinkShell8,
|
|
];
|
|
|
|
public ChatMonitorService(
|
|
IChatGui chatGui,
|
|
PartyInviteService partyInviteService,
|
|
IPluginLog log,
|
|
HSRToolsConfiguration config)
|
|
{
|
|
_chatGui = chatGui;
|
|
_partyInviteService = partyInviteService;
|
|
_log = log;
|
|
_config = config;
|
|
}
|
|
|
|
public void SetConfiguration(HSRToolsConfiguration config)
|
|
{
|
|
_config = config;
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
_chatGui.ChatMessage += OnChatMessage;
|
|
_chatGui.CheckMessageHandled += OnCheckMessageHandled;
|
|
PluginServices.ToastGui.Toast += OnToast;
|
|
PluginServices.ToastGui.ErrorToast += OnErrorToast;
|
|
PluginServices.ToastGui.QuestToast += OnQuestToast;
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
PluginServices.ToastGui.QuestToast -= OnQuestToast;
|
|
PluginServices.ToastGui.ErrorToast -= OnErrorToast;
|
|
PluginServices.ToastGui.Toast -= OnToast;
|
|
_chatGui.ChatMessage -= OnChatMessage;
|
|
_chatGui.CheckMessageHandled -= OnCheckMessageHandled;
|
|
}
|
|
|
|
private static bool IsPartyErrorToSuppress(string msg)
|
|
{
|
|
if (string.IsNullOrEmpty(msg)) return false;
|
|
var ord = StringComparison.OrdinalIgnoreCase;
|
|
return (msg.Contains("locate", ord) && msg.Contains("that name", ord)) ||
|
|
msg.Contains("Unable to process party command", ord) ||
|
|
(msg.Contains("already", ord) && msg.Contains("invited", ord));
|
|
}
|
|
|
|
/// <summary>Suppress party-error toasts (big on-screen message) from our fallback invite. See BurntToast plugin.</summary>
|
|
private void OnErrorToast(ref SeString message, ref bool isHandled)
|
|
{
|
|
if (DateTime.UtcNow >= _suppressPartyErrorUntil || isHandled)
|
|
return;
|
|
if (IsPartyErrorToSuppress(message.TextValue))
|
|
{
|
|
isHandled = true;
|
|
_suppressPartyErrorUntil = DateTime.MinValue;
|
|
}
|
|
}
|
|
|
|
private void OnToast(ref SeString message, ref Dalamud.Game.Gui.Toast.ToastOptions options, ref bool isHandled)
|
|
{
|
|
if (DateTime.UtcNow >= _suppressPartyErrorUntil || isHandled)
|
|
return;
|
|
if (IsPartyErrorToSuppress(message.TextValue))
|
|
{
|
|
isHandled = true;
|
|
_suppressPartyErrorUntil = DateTime.MinValue;
|
|
}
|
|
}
|
|
|
|
private void OnQuestToast(ref SeString message, ref Dalamud.Game.Gui.Toast.QuestToastOptions options, ref bool isHandled)
|
|
{
|
|
if (DateTime.UtcNow >= _suppressPartyErrorUntil || isHandled)
|
|
return;
|
|
if (IsPartyErrorToSuppress(message.TextValue))
|
|
{
|
|
isHandled = true;
|
|
_suppressPartyErrorUntil = DateTime.MinValue;
|
|
}
|
|
}
|
|
|
|
/// <summary>Suppress party-error messages in chat (CheckMessageHandled path).</summary>
|
|
private void OnCheckMessageHandled(XivChatType type, int timestamp, ref SeString sender, ref SeString message, ref bool isHandled)
|
|
{
|
|
if (DateTime.UtcNow >= _suppressPartyErrorUntil)
|
|
return;
|
|
var msg = message.TextValue;
|
|
if (msg.Length == 0)
|
|
return;
|
|
if (IsPartyErrorToSuppress(msg))
|
|
{
|
|
isHandled = true;
|
|
_suppressPartyErrorUntil = DateTime.MinValue;
|
|
}
|
|
}
|
|
|
|
private void OnChatMessage(XivChatType type, int timestamp, ref SeString sender, ref SeString message, ref bool isHandled)
|
|
{
|
|
var msg = message.TextValue;
|
|
// Suppress party-error messages from our fallback invite (same-world CWLS tries ContentId then name+world; first can fail).
|
|
if (DateTime.UtcNow < _suppressPartyErrorUntil && IsPartyErrorToSuppress(msg))
|
|
{
|
|
isHandled = true;
|
|
_suppressPartyErrorUntil = DateTime.MinValue;
|
|
return;
|
|
}
|
|
|
|
if (!_config.Enabled || string.IsNullOrWhiteSpace(_config.TriggerText))
|
|
return;
|
|
|
|
if (!IsMonitoredChannel(type))
|
|
return;
|
|
|
|
var messageText = message.TextValue;
|
|
var trigger = _config.TriggerText;
|
|
var comparison = _config.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
|
|
|
|
if (!messageText.Contains(trigger, comparison))
|
|
return;
|
|
|
|
// Read log module first for non-tells (sender data only valid at callback start; overwritten quickly).
|
|
var (logContentId, logWorldId) = type != XivChatType.TellIncoming
|
|
? PartyInviteService.GetCurrentChatSenderFromLogModule()
|
|
: (0UL, (ushort)0);
|
|
|
|
// Try to get name and world from the sender's link payload (the clickable name — world is in the payload, not visible text)
|
|
var (senderName, worldIdFromPayload) = TryGetSenderFromPayloads(sender);
|
|
if (string.IsNullOrWhiteSpace(senderName))
|
|
{
|
|
senderName = sender.TextValue.Trim();
|
|
if (string.IsNullOrWhiteSpace(senderName))
|
|
return;
|
|
}
|
|
|
|
ulong contentId = 0;
|
|
ushort worldId = _partyInviteService.GetLocalWorldId();
|
|
var localWorldId = _partyInviteService.GetLocalWorldId();
|
|
|
|
if (type == XivChatType.TellIncoming)
|
|
{
|
|
var (lastContentId, lastWorldId) = PartyInviteService.GetLastTellSenderInfo();
|
|
if (lastContentId != 0)
|
|
{
|
|
contentId = lastContentId;
|
|
worldId = lastWorldId;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// FC/LS/CWLS: set worldId from payload (or local).
|
|
if (worldIdFromPayload.HasValue && worldIdFromPayload.Value != 0)
|
|
worldId = worldIdFromPayload.Value;
|
|
|
|
var isFcOrLs = type == XivChatType.FreeCompany || Array.IndexOf(LinkShellTypes, type) >= 0;
|
|
var isCwls = Array.IndexOf(CrossWorldLinkShellTypes, type) >= 0;
|
|
|
|
if (isCwls && logContentId != 0)
|
|
{
|
|
contentId = logContentId;
|
|
if (logWorldId != 0)
|
|
worldId = logWorldId;
|
|
}
|
|
else if (isFcOrLs)
|
|
{
|
|
contentId = 0;
|
|
}
|
|
}
|
|
|
|
if (_config.Debug)
|
|
{
|
|
_log.Info($"[HSRTools Debug] {type} name='{senderName}' worldId={worldId} localWorldId={localWorldId} contentId={contentId} logContentId={logContentId} (0=name+world, non-zero=ContentId)");
|
|
}
|
|
|
|
// Same-world CWLS with contentId uses fallback (ContentId then name+world); first attempt can show an error — suppress it.
|
|
var isSameWorldCwls = Array.IndexOf(CrossWorldLinkShellTypes, type) >= 0 && contentId != 0 && localWorldId != 0 && worldId == localWorldId;
|
|
if (isSameWorldCwls)
|
|
_suppressPartyErrorUntil = DateTime.UtcNow.Add(SuppressPartyErrorWindow);
|
|
|
|
_log.Info($"Trigger \"{trigger}\" detected from {senderName} in {type}. Sending party invite.");
|
|
var success = _partyInviteService.TryInviteToParty(senderName, contentId, worldId);
|
|
if (_config.Debug)
|
|
_log.Info($"[HSRTools Debug] Invite result: {success}");
|
|
if (!success)
|
|
_log.Warning($"Failed to send party invite to {senderName}.");
|
|
}
|
|
|
|
private bool IsMonitoredChannel(XivChatType type)
|
|
{
|
|
return type switch
|
|
{
|
|
XivChatType.FreeCompany => _config.MonitorFreeCompany,
|
|
XivChatType.TellIncoming => _config.MonitorTell,
|
|
_ when Array.IndexOf(LinkShellTypes, type) >= 0 => _config.MonitorLinkShell,
|
|
_ when Array.IndexOf(CrossWorldLinkShellTypes, type) >= 0 => _config.MonitorCrossWorldLinkShell,
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts player name and world ID from the sender's SeString when it contains a PlayerPayload
|
|
/// (the clickable name link — world is stored in the payload, not in the visible text).
|
|
/// Returns (name, worldId) when found; (null, null) otherwise.
|
|
/// </summary>
|
|
private static (string? Name, ushort? WorldId) TryGetSenderFromPayloads(SeString sender)
|
|
{
|
|
if (sender.Payloads == null)
|
|
return (null, null);
|
|
|
|
foreach (var payload in sender.Payloads)
|
|
{
|
|
if (payload is PlayerPayload playerPayload)
|
|
{
|
|
var name = playerPayload.PlayerName?.Trim();
|
|
if (string.IsNullOrEmpty(name))
|
|
continue;
|
|
var rowId = playerPayload.World.RowId;
|
|
var worldId = rowId <= ushort.MaxValue ? (ushort)rowId : (ushort)0;
|
|
return (name, worldId);
|
|
}
|
|
}
|
|
return (null, null);
|
|
}
|
|
}
|