a651b3b9ad
Four pre-existing upstream defects fixed in v1.0.0: - Util/GlobalParametersCache.cs GetValue captures Cache into a local before the bounds check, so the check and the indexed read operate on the same array reference even when Refresh reassigns Cache from the main thread between the two operations - Util/IconUtil.cs binary search bounds: hi initialized to entries.Length-1 (was Length), and reset on redirect-restart; added entries.Length==0 short-circuit to prevent indexing into empty arrays - Sheets.cs WorldsOnDatacenter compared Region.RowId, which groups by region instead of datacenter — now compares DataCenter.RowId directly so the result actually reflects same-DC worlds - Message.cs back-reference loop iterates the processed Sender/Content properties rather than the raw constructor parameters, so chunks added or replaced by CheckMessageContent also get Message set
346 lines
14 KiB
C#
Executable File
346 lines
14 KiB
C#
Executable File
using System.Text;
|
|
using HellionChat.Code;
|
|
using HellionChat.Util;
|
|
using Dalamud.Game.Text.SeStringHandling;
|
|
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
|
using System.Text.RegularExpressions;
|
|
using Dalamud.Game.Text;
|
|
using Dalamud.Utility;
|
|
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
|
|
|
namespace HellionChat;
|
|
|
|
public partial class Message
|
|
{
|
|
public Guid Id { get; } = Guid.NewGuid();
|
|
public ulong Receiver { get; }
|
|
public ulong ContentId { get; set; }
|
|
public ulong AccountId { get; set; } // 0 if not set
|
|
|
|
public DateTimeOffset Date { get; }
|
|
public ChatCode Code { get; }
|
|
public List<Chunk> Sender { get; }
|
|
public List<Chunk> Content { get; private set; }
|
|
|
|
public SeString SenderSource { get; }
|
|
public SeString ContentSource { get; }
|
|
|
|
public int SortCodeV2 { get; }
|
|
public Guid ExtraChatChannel { get; }
|
|
|
|
// Not stored in the database:
|
|
public int Hash { get; }
|
|
public Dictionary<Guid, float?> Height { get; } = new();
|
|
public Dictionary<Guid, bool> IsVisible { get; } = new();
|
|
|
|
public Message(ulong receiver, ulong contentId, ulong accountId, ChatCode code, List<Chunk> sender, List<Chunk> content, SeString senderSource, SeString contentSource)
|
|
{
|
|
var extraChatChannel = ExtractExtraChatChannel(contentSource);
|
|
Receiver = receiver;
|
|
ContentId = contentId;
|
|
AccountId = accountId;
|
|
Date = DateTimeOffset.UtcNow;
|
|
Code = code;
|
|
Sender = sender;
|
|
Content = CheckMessageContent(content, extraChatChannel);
|
|
SenderSource = senderSource;
|
|
ContentSource = contentSource;
|
|
SortCodeV2 = Code.ToSortCodeV2();
|
|
ExtraChatChannel = extraChatChannel;
|
|
Hash = GenerateHash();
|
|
|
|
// Iterate the processed Content list (returned by CheckMessageContent)
|
|
// rather than the raw constructor parameter — chunks added or replaced
|
|
// by CheckMessageContent must also have their back-reference set.
|
|
foreach (var chunk in Sender.Concat(Content))
|
|
chunk.Message = this;
|
|
}
|
|
|
|
public Message(Guid id, ulong receiver, ulong contentId, DateTimeOffset date, ChatCode code, List<Chunk> sender, List<Chunk> content, SeString senderSource, SeString contentSource, Guid extraChatChannel)
|
|
{
|
|
Id = id;
|
|
Receiver = receiver;
|
|
ContentId = contentId;
|
|
Date = date;
|
|
Code = code;
|
|
Sender = sender;
|
|
// Don't call ReplaceContentURLs here since we're loading the message
|
|
// from the database, and it should already have parsed URL data.
|
|
Content = content;
|
|
SenderSource = senderSource;
|
|
ContentSource = contentSource;
|
|
SortCodeV2 = code.ToSortCodeV2();
|
|
ExtraChatChannel = extraChatChannel;
|
|
Hash = GenerateHash();
|
|
|
|
foreach (var chunk in sender.Concat(content))
|
|
chunk.Message = this;
|
|
}
|
|
|
|
public static Message FakeMessage(List<Chunk> content, ChatCode code)
|
|
{
|
|
return new Message(0, 0, 0, code, [], content, new SeString(), new SeString());
|
|
}
|
|
|
|
public bool Matches(Dictionary<ChatType, (ChatSource Source, ChatSource Target)> channels, bool allExtraChatChannels, HashSet<Guid> extraChatChannels)
|
|
{
|
|
if (ExtraChatChannel != Guid.Empty)
|
|
return allExtraChatChannels || extraChatChannels.Contains(ExtraChatChannel);
|
|
|
|
var source = (ChatSource)(1 << (int)Code.Source);
|
|
var target = (ChatSource)(1 << (int)Code.Target);
|
|
return Code.Type.IsGm()
|
|
|| channels.TryGetValue(Code.Type, out var sources)
|
|
&& (Code.Source is 0 || sources.Source.HasFlag(source) || sources.Target.HasFlag(target));
|
|
}
|
|
|
|
private int GenerateHash()
|
|
{
|
|
var hash = SortCodeV2.GetHashCode()
|
|
^ ExtraChatChannel.GetHashCode()
|
|
^ string.Join("", Sender.Select(c => c.StringValue())).GetHashCode()
|
|
^ string.Join("", Content.Select(c => c.StringValue())).GetHashCode();
|
|
|
|
if (Plugin.Config.CollapseKeepUniqueLinks)
|
|
{
|
|
// Hash the link too for something like DeathRecap where the message is the same
|
|
// but the link is different
|
|
hash ^= string.Join("", Content.Select(c => c.Link?.GetHashCode())).GetHashCode();
|
|
}
|
|
|
|
return hash;
|
|
}
|
|
|
|
private static Guid ExtractExtraChatChannel(SeString contentSource)
|
|
{
|
|
if (contentSource.Payloads.Count > 0 && contentSource.Payloads[0] is RawPayload raw)
|
|
{
|
|
// this does an encode and clone every time it's accessed, so cache
|
|
var data = raw.Data;
|
|
try
|
|
{
|
|
if (data[1] == 0x27 && data[2] == 18 && data[3] == 0x20)
|
|
return new Guid(data[4..^1]);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
Plugin.Log.Error(ex, "Failed to parse extra chat channel GUID");
|
|
Plugin.Log.Error($"Byte Array: ${string.Join(", ", data[4..^1])}");
|
|
return Guid.Empty;
|
|
}
|
|
}
|
|
|
|
return Guid.Empty;
|
|
}
|
|
|
|
private List<Chunk> CheckMessageContent(List<Chunk> oldChunks, Guid extraChatChannel)
|
|
{
|
|
var newChunks = new List<Chunk>();
|
|
void AddChunkWithMessage(TextChunk chunk)
|
|
{
|
|
if (string.IsNullOrEmpty(chunk.Content))
|
|
return;
|
|
|
|
chunk.Message = this;
|
|
newChunks.Add(chunk);
|
|
}
|
|
|
|
var nextIsAutoTranslate = false;
|
|
var checkForEmotes = (Code.IsPlayerMessage() || extraChatChannel != Guid.Empty) && Plugin.Config.ShowEmotes;
|
|
foreach (var chunk in oldChunks)
|
|
{
|
|
// Use as is if it's not a text chunk, it already has a payload, or is auto translate
|
|
if (chunk is not TextChunk text || chunk.Link != null || nextIsAutoTranslate)
|
|
{
|
|
nextIsAutoTranslate = chunk is IconChunk { Icon: BitmapFontIcon.AutoTranslateBegin };
|
|
|
|
// No need to call AddChunkWithMessage here since the chunk
|
|
// already has the Message field set.
|
|
newChunks.Add(chunk);
|
|
continue;
|
|
}
|
|
|
|
var wordBuilder = new StringBuilder();
|
|
var sentenceBuilder = new StringBuilder();
|
|
foreach (var token in Tokenizer.PrecedenceBasedRegexTokenizer.Tokenize(text.Content))
|
|
{
|
|
if (token.TokenType == Tokenizer.TokenType.StringValue)
|
|
{
|
|
wordBuilder.Append(token.Value);
|
|
continue;
|
|
}
|
|
|
|
var word = wordBuilder.ToString();
|
|
wordBuilder.Clear();
|
|
|
|
|
|
var wordUsed = false;
|
|
var tokenUsed = false;
|
|
|
|
if (checkForEmotes && EmoteCache.Exists(word) && !Plugin.Config.BlockedEmotes.Contains(word))
|
|
{
|
|
// Add the previous sentence before adding the emote
|
|
AddChunkWithMessage(text.NewWithStyle(chunk, sentenceBuilder.ToString()));
|
|
AddChunkWithMessage(new TextChunk(chunk.Source, EmotePayload.ResolveEmote(word), word) { FallbackColour = text.FallbackColour });
|
|
|
|
wordUsed = true;
|
|
sentenceBuilder.Clear();
|
|
}
|
|
|
|
if (token.TokenType == Tokenizer.TokenType.UrlString)
|
|
{
|
|
// Add the previous sentence before adding the url
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, sentenceBuilder.Append(!wordUsed ? word : "").ToString()));
|
|
try
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, UriPayload.ResolveUri(token.Value), token.Value));
|
|
}
|
|
catch (UriFormatException)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, token.Value));
|
|
Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{token.Value}'");
|
|
}
|
|
|
|
wordUsed = true;
|
|
tokenUsed = true;
|
|
sentenceBuilder.Clear();
|
|
}
|
|
|
|
// Append match if we haven't reached end of string yet
|
|
if (token.TokenType != Tokenizer.TokenType.SequenceTerminator)
|
|
{
|
|
sentenceBuilder.Append(!wordUsed ? word : "");
|
|
sentenceBuilder.Append(!tokenUsed ? token.Value : "");
|
|
continue;
|
|
}
|
|
|
|
// End of string reached, we add our leftover
|
|
AddChunkWithMessage(text.NewWithStyle(chunk, sentenceBuilder.Append(!wordUsed ? word : "").ToString()));
|
|
}
|
|
}
|
|
|
|
return newChunks;
|
|
}
|
|
|
|
public unsafe void DecodeTextParam()
|
|
{
|
|
var newChunks = new List<Chunk>();
|
|
void AddChunkWithMessage(TextChunk chunk)
|
|
{
|
|
if (string.IsNullOrEmpty(chunk.Content))
|
|
return;
|
|
|
|
chunk.Message = this;
|
|
newChunks.Add(chunk);
|
|
}
|
|
|
|
foreach (var chunk in Content)
|
|
{
|
|
// Use as is if it's not a text chunk or it already has a payload.
|
|
if (chunk is not TextChunk text || chunk.Link != null)
|
|
{
|
|
// No need to call AddChunkWithMessage here since the chunk
|
|
// already has the Message field set.
|
|
newChunks.Add(chunk);
|
|
continue;
|
|
}
|
|
|
|
var splits = TextParamRegex().Split(text.Content);
|
|
if (splits.Length == 1)
|
|
{
|
|
newChunks.Add(chunk);
|
|
continue;
|
|
}
|
|
|
|
var nextIsMatch = false;
|
|
foreach (var split in splits)
|
|
{
|
|
if (split == "" || !nextIsMatch)
|
|
{
|
|
nextIsMatch = true;
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
continue;
|
|
}
|
|
|
|
nextIsMatch = false;
|
|
try
|
|
{
|
|
if (split == "<item>")
|
|
{
|
|
var agentChat = AgentChatLog.Instance();
|
|
var item = agentChat->LinkedItem;
|
|
|
|
if (item.ItemId == 0)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
continue;
|
|
}
|
|
|
|
var kind = item.ItemId switch
|
|
{
|
|
< 500_000 => ItemKind.Normal,
|
|
< 1_000_000 => ItemKind.Collectible,
|
|
< 2_000_000 => ItemKind.Hq,
|
|
_ => ItemKind.EventItem
|
|
};
|
|
|
|
var name = kind != ItemKind.EventItem
|
|
? Sheets.ItemSheet.GetRow(item.ItemId).Name.ToString()
|
|
: Sheets.EventItemSheet.GetRow(item.ItemId).Name.ToString();
|
|
|
|
var link = new ItemPayload(item.ItemId, kind, $"{SeIconChar.LinkMarker.ToIconChar()}{name}");
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, link.DisplayName ?? "Unknown"));
|
|
}
|
|
else if (split == "<status>")
|
|
{
|
|
var statusId = AgentChatLog.Instance()->ContextStatusId;
|
|
if (statusId == 0 || !Sheets.StatusSheet.TryGetRow(statusId, out var statusRow))
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
continue;
|
|
}
|
|
|
|
var nameValue = statusRow.Name.ToString();
|
|
var content = statusRow.StatusCategory switch
|
|
{
|
|
1 => $"{SeIconChar.Buff.ToIconString()}{nameValue}",
|
|
2 => $"{SeIconChar.Debuff.ToIconString()}{nameValue}",
|
|
_ => nameValue
|
|
};
|
|
|
|
var link = new StatusPayload(statusId);
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, content));
|
|
}
|
|
else if (split == "<flag>")
|
|
{
|
|
var agentMap = AgentMap.Instance();
|
|
if (agentMap->FlagMarkerCount == 0)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
continue;
|
|
}
|
|
|
|
var mapCoords = agentMap->FlagMapMarkers[0];
|
|
var rawX = (int)(MathF.Round(mapCoords.XFloat, 3, MidpointRounding.AwayFromZero) * 1000);
|
|
var rawY = (int)(MathF.Round(mapCoords.YFloat, 3, MidpointRounding.AwayFromZero) * 1000);
|
|
|
|
var link = new MapLinkPayload(mapCoords.TerritoryId, mapCoords.MapId, rawX, rawY);
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, $"{SeIconChar.LinkMarker.ToIconChar()}{link.PlaceName} {link.CoordinateString}"));
|
|
}
|
|
|
|
}
|
|
catch (Exception)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
Plugin.Log.Debug($"Failed to parse the text param: '{split}'");
|
|
}
|
|
}
|
|
}
|
|
|
|
Content = newChunks;
|
|
}
|
|
|
|
[GeneratedRegex("(<item>|<flag>|<status>)")]
|
|
private static partial Regex TextParamRegex();
|
|
}
|