build: rename repository folder ChatTwo to HellionChat
Repository folder, csproj, solution and all CI/build paths now use
the consolidated HellionChat name.
- ChatTwo/ → HellionChat/ (git mv preserves history with --follow)
- ChatTwo.csproj → HellionChat.csproj
- ChatTwo.sln → HellionChat.sln; obsolete Tests project entry removed
(private/untracked sandbox)
- AssemblyInfo.cs InternalsVisibleTo for ChatTwo.Tests removed
(file emptied; can be repopulated when actual tests land)
- repo.json and yaml image URLs updated (ChatTwo/images/ → HellionChat/images/)
- .github/workflows/{build,codeql,release}.yml csproj paths
- .github/dependabot.yml directory path
Functional behavior unchanged.
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Util;
|
||||
using Dalamud.Game.Chat;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Hooking;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
using Lumina.Text.Expressions;
|
||||
using Lumina.Text.Payloads;
|
||||
using Lumina.Text.ReadOnly;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
internal class MessageManager : IAsyncDisposable
|
||||
{
|
||||
internal const int MessageDisplayLimit = 10_000;
|
||||
|
||||
private Plugin Plugin { get; }
|
||||
internal MessageStore Store { get; }
|
||||
|
||||
private Dictionary<ChatType, NameFormatting> Formats { get; } = [];
|
||||
private ulong LastContentId { get; set; }
|
||||
|
||||
// Messages go into the PendingSync queue first, which will be consumed one
|
||||
// at a time in the main thread. This is to delay the async processing until
|
||||
// after we've received the content ID from the ContentIdResolver hook.
|
||||
//
|
||||
// After that, the message is enqueued in the PendingAsync queue, which will
|
||||
// be consumed in a separate thread and perform more processing (emotes,
|
||||
// URLs) as well as inserting the message into the database.
|
||||
private Queue<PendingMessage> PendingSync { get; } = [];
|
||||
private ConcurrentQueue<PendingMessage> PendingAsync { get; } = [];
|
||||
private readonly Thread PendingMessageThread;
|
||||
private readonly CancellationTokenSource PendingThreadCancellationToken = new();
|
||||
|
||||
private Hook<RaptureLogModule.Delegates.AddMsgSourceEntry>? ContentIdResolverHook { get; init; }
|
||||
|
||||
internal ulong CurrentContentId
|
||||
{
|
||||
get
|
||||
{
|
||||
var contentId = Plugin.PlayerState.ContentId;
|
||||
return contentId == 0 ? LastContentId : contentId;
|
||||
}
|
||||
}
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs hook. Fires after a fully processed
|
||||
// message has been routed to all matching persistent tabs and stored
|
||||
// in the database. The AutoTellTabsService subscribes to spawn or
|
||||
// refresh temp tabs without having to wedge itself into ProcessMessage
|
||||
// directly.
|
||||
public event Action<Message>? MessageProcessed;
|
||||
|
||||
internal unsafe MessageManager(Plugin plugin)
|
||||
{
|
||||
Plugin = plugin;
|
||||
|
||||
Store = new MessageStore(DatabasePath());
|
||||
|
||||
PendingMessageThread = new Thread(() => ProcessPendingMessages(PendingThreadCancellationToken.Token));
|
||||
PendingMessageThread.Start();
|
||||
|
||||
ContentIdResolverHook = Plugin.GameInteropProvider.HookFromAddress<RaptureLogModule.Delegates.AddMsgSourceEntry>(RaptureLogModule.MemberFunctionPointers.AddMsgSourceEntry, ContentIdResolver);
|
||||
ContentIdResolverHook.Enable();
|
||||
|
||||
Plugin.ChatGui.ChatMessageUnhandled += ChatMessage;
|
||||
Plugin.Framework.Update += OnFrameworkUpdate;
|
||||
Plugin.ClientState.Logout += Logout;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
ContentIdResolverHook?.Dispose();
|
||||
Plugin.ClientState.Logout -= Logout;
|
||||
Plugin.Framework.Update -= OnFrameworkUpdate;
|
||||
Plugin.ChatGui.ChatMessageUnhandled -= ChatMessage;
|
||||
|
||||
await PendingThreadCancellationToken.CancelAsync();
|
||||
var timeout = 10_000; // 10s
|
||||
while (timeout > 0)
|
||||
{
|
||||
if (!PendingMessageThread.IsAlive)
|
||||
break;
|
||||
|
||||
timeout -= 100;
|
||||
await Task.Delay(100);
|
||||
Plugin.Log.Debug("Sleeping because PendingMessageThread thread still alive");
|
||||
}
|
||||
|
||||
Store.Dispose();
|
||||
}
|
||||
|
||||
internal static string DatabasePath()
|
||||
{
|
||||
return Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-sqlite.db");
|
||||
}
|
||||
|
||||
private void Logout(int _, int __)
|
||||
{
|
||||
LastContentId = 0;
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate(IFramework framework)
|
||||
{
|
||||
var contentId = Plugin.PlayerState.ContentId;
|
||||
if (contentId != 0)
|
||||
LastContentId = contentId;
|
||||
|
||||
// Drain the PendingSync queue into the PendingAsync queue.
|
||||
while (PendingSync.TryDequeue(out var pending))
|
||||
PendingAsync.Enqueue(pending);
|
||||
}
|
||||
|
||||
private void ProcessPendingMessages(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
if (PendingAsync.TryDequeue(out var pendingMessage))
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessMessage(pendingMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error processing pending message");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void ClearAllTabs()
|
||||
{
|
||||
foreach (var tab in Plugin.Config.Tabs)
|
||||
tab.Clear();
|
||||
}
|
||||
|
||||
internal void FilterAllTabs()
|
||||
{
|
||||
DateTimeOffset? since = null;
|
||||
if (!Plugin.Config.FilterIncludePreviousSessions)
|
||||
since = Plugin.GameStarted;
|
||||
|
||||
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
|
||||
|
||||
// We store the pending messages to be added to the chat log in a
|
||||
// temporary list, and apply them all at once after filtering.
|
||||
var pendingTabs = Plugin.Config.Tabs.Select(tab => (tab, new List<Message>())).ToList();
|
||||
foreach (var message in messages)
|
||||
foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message)))
|
||||
pendingMessages.Add(message);
|
||||
|
||||
// Apply the messages to the chat log in one go.
|
||||
foreach (var (tab, pendingMessages) in pendingTabs)
|
||||
tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit);
|
||||
|
||||
if (!messages.DidError) return;
|
||||
|
||||
WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error);
|
||||
|
||||
// Mark the failed messages as deleted so we don't try to load them
|
||||
// again.
|
||||
var failedIds = messages.FailedMessageIds();
|
||||
Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures");
|
||||
foreach (var msgId in messages.FailedMessageIds())
|
||||
{
|
||||
Plugin.Log.Debug($"Marking message '{msgId}' as deleted due to parse failure");
|
||||
Store.DeleteMessage(msgId);
|
||||
}
|
||||
}
|
||||
|
||||
internal void FilterAllTabsAsync()
|
||||
{
|
||||
Task.Run(() =>
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
FilterAllTabs();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error in FilterAllTabs");
|
||||
}
|
||||
|
||||
Plugin.Log.Debug($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms");
|
||||
});
|
||||
}
|
||||
|
||||
public (SeString? Sender, SeString? Message) LastMessage = (null, null);
|
||||
private void ChatMessage(IChatMessage message)
|
||||
{
|
||||
LastMessage = (message.Sender, message.Message);
|
||||
|
||||
var pendingMessage = new PendingMessage
|
||||
{
|
||||
ContentId = 0,
|
||||
AccountId = 0,
|
||||
LogKind = message.LogKind,
|
||||
SourceKind = message.SourceKind,
|
||||
TargetKind = message.TargetKind,
|
||||
Sender = message.Sender,
|
||||
Content = message.Message,
|
||||
};
|
||||
|
||||
// Update colour codes.
|
||||
GlobalParametersCache.Refresh();
|
||||
|
||||
// We delay messages to be handed off to the async processing thread
|
||||
// in the next tick, otherwise we can't get the content ID from the hook
|
||||
// below.
|
||||
PendingSync.Enqueue(pendingMessage);
|
||||
}
|
||||
|
||||
// This hook is called immediately after receiving a message with the
|
||||
// message's content ID. If multiple messages are received in the same tick,
|
||||
// this will be called for each message immediately after ChatMessage is
|
||||
// called for each message.
|
||||
private unsafe void ContentIdResolver(RaptureLogModule* agent, ulong contentId, ulong accountId, int messageIndex, ushort worldId, ushort chatType)
|
||||
{
|
||||
try
|
||||
{
|
||||
ContentIdResolverHook?.Original(agent, contentId, accountId, messageIndex, worldId, chatType);
|
||||
if (PendingSync.Count == 0)
|
||||
return;
|
||||
|
||||
PendingSync.Last().ContentId = contentId;
|
||||
PendingSync.Last().AccountId = accountId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error in ContentIdResolver");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessMessage(PendingMessage pendingMessage)
|
||||
{
|
||||
var chatCode = new ChatCode(pendingMessage.LogKind, pendingMessage.SourceKind, pendingMessage.TargetKind);
|
||||
|
||||
NameFormatting? formatting = null;
|
||||
if (pendingMessage.Sender.Payloads.Count > 0)
|
||||
formatting = FormatFor(chatCode.Type);
|
||||
|
||||
var senderChunks = new List<Chunk>();
|
||||
if (formatting is { IsPresent: true })
|
||||
{
|
||||
senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.Before) { FallbackColour = chatCode.Type });
|
||||
senderChunks.AddRange(ChunkUtil.ToChunks(pendingMessage.Sender, ChunkSource.Sender, chatCode.Type));
|
||||
senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.After) { FallbackColour = chatCode.Type });
|
||||
}
|
||||
|
||||
var contentChunks = ChunkUtil.ToChunks(pendingMessage.Content, ChunkSource.Content, chatCode.Type).ToList();
|
||||
var message = new Message(CurrentContentId, pendingMessage.ContentId, pendingMessage.AccountId, chatCode, senderChunks, contentChunks, pendingMessage.Sender, pendingMessage.Content);
|
||||
|
||||
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
|
||||
Store.UpsertMessage(message);
|
||||
|
||||
var currentMatches = Plugin.CurrentTab.Matches(message);
|
||||
foreach (var tab in Plugin.Config.Tabs)
|
||||
{
|
||||
var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches);
|
||||
|
||||
if (tab.Matches(message))
|
||||
tab.AddMessage(message, unread);
|
||||
}
|
||||
|
||||
MessageProcessed?.Invoke(message);
|
||||
}
|
||||
|
||||
internal class NameFormatting
|
||||
{
|
||||
internal string Before { get; private set; } = string.Empty;
|
||||
internal string After { get; private set; } = string.Empty;
|
||||
internal bool IsPresent { get; private set; } = true;
|
||||
|
||||
internal static NameFormatting Empty()
|
||||
{
|
||||
return new NameFormatting { IsPresent = false, };
|
||||
}
|
||||
|
||||
internal static NameFormatting Of(string before, string after)
|
||||
{
|
||||
return new NameFormatting
|
||||
{
|
||||
Before = before,
|
||||
After = after,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private NameFormatting FormatFor(ChatType type)
|
||||
{
|
||||
if (Formats.TryGetValue(type, out var cached))
|
||||
return cached;
|
||||
|
||||
var formats = Sheets.LogKindSheet.GetRow((uint)type).Format.ToList();
|
||||
static bool IsStringParam(ReadOnlySePayload payload, byte num)
|
||||
{
|
||||
if (payload.MacroCode != MacroCode.String)
|
||||
return false;
|
||||
|
||||
return payload.TryGetExpression(out var expr1)
|
||||
&& expr1.TryGetParameterExpression(out var expressionType, out var operand)
|
||||
&& expressionType == (byte)ExpressionType.LocalString
|
||||
&& operand.TryGetInt(out var lstrIndex)
|
||||
&& lstrIndex == num;
|
||||
}
|
||||
|
||||
var firstStringParam = formats.FindIndex(payload => IsStringParam(payload, 1));
|
||||
var secondStringParam = formats.FindIndex(payload => IsStringParam(payload, 2));
|
||||
|
||||
if (firstStringParam == -1 || secondStringParam == -1)
|
||||
return NameFormatting.Empty();
|
||||
|
||||
var before = formats
|
||||
.GetRange(0, firstStringParam)
|
||||
.Where(payload => payload.Type == ReadOnlySePayloadType.Text)
|
||||
.Select(text => Encoding.UTF8.GetString(text.Body.Span));
|
||||
var after = formats
|
||||
.GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
|
||||
.Where(payload => payload.Type == ReadOnlySePayloadType.Text)
|
||||
.Select(text => Encoding.UTF8.GetString(text.Body.Span)); // Can't use `ToString()` as it defaults to macro
|
||||
|
||||
var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after));
|
||||
Formats[type] = nameFormatting;
|
||||
|
||||
return nameFormatting;
|
||||
}
|
||||
|
||||
private class PendingMessage
|
||||
{
|
||||
public ulong ContentId; // 0 if unknown
|
||||
public ulong AccountId; // 0 if unknown
|
||||
public XivChatType LogKind;
|
||||
public XivChatRelationKind SourceKind;
|
||||
public XivChatRelationKind TargetKind;
|
||||
public required SeString Sender;
|
||||
public required SeString Content;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user