feat(auto-tell-tabs): add GetTellHistoryWithSender query and ChunkUtil sender helper

This commit is contained in:
2026-05-02 12:52:58 +02:00
parent 07f47f32e3
commit 92bb368d2b
4 changed files with 280 additions and 30 deletions
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.IO;
using System.Linq;
using ChatTwo.Code;
using ChatTwo.Util;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using JetBrains.Annotations;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ChatTwo.Tests;
// Hellion Chat — Auto-Tell-Tabs history-preload coverage.
//
// These tests exercise MessageStore.GetTellHistoryWithSender, the query the
// AutoTellTabsService uses to populate a freshly spawned temp tab with the
// last conversations with that player.
//
// NOTE: like the rest of ChatTwo.Tests today, these will fail at runtime
// until the project's Dalamud.dll runtime dependency is sorted out (see
// Phase-2 backlog item "Test-Projekt fixen"). Compile-time the suite builds
// fine via DALAMUD_HOME, so the tests guard against API drift even before
// they can be executed locally.
[TestClass]
[TestSubject(typeof(MessageStore))]
public class AutoTellTabsHistoryTest
{
public TestContext TestContext { get; set; }
[TestMethod]
[Timeout(5000)]
public void GetTellHistoryWithSender_FiltersByNameAndWorld()
{
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
var dbPath = Path.Join(tempDir.FullName, "test.db");
TestContext.WriteLine("Using database path: " + dbPath);
using var store = new MessageStore(dbPath);
const ulong receiver = 99001;
var now = DateTimeOffset.UtcNow;
// Two tells with the target sender, one with a different sender on
// the same world, one with the same name on a different world. Only
// the first two should make it into the result.
var asukaLichIn = TellMessage("Asuka", 76, receiver, now.AddMinutes(-30), ChatType.TellIncoming);
var asukaLichOut = TellMessage("Asuka", 76, receiver, now.AddMinutes(-20), ChatType.TellOutgoing);
var broboLich = TellMessage("Brobo", 76, receiver, now.AddMinutes(-10), ChatType.TellIncoming);
var asukaOmega = TellMessage("Asuka", 90, receiver, now.AddMinutes(-5), ChatType.TellIncoming);
store.UpsertMessage(asukaLichIn);
store.UpsertMessage(asukaLichOut);
store.UpsertMessage(broboLich);
store.UpsertMessage(asukaOmega);
var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 50);
Assert.AreEqual(2, result.Count);
// Result is oldest-first so a tab can append messages chronologically.
Assert.AreEqual(asukaLichIn.Id, result[0].Id);
Assert.AreEqual(asukaLichOut.Id, result[1].Id);
}
[TestMethod]
[Timeout(5000)]
public void GetTellHistoryWithSender_RespectsLimit()
{
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
var dbPath = Path.Join(tempDir.FullName, "test.db");
TestContext.WriteLine("Using database path: " + dbPath);
using var store = new MessageStore(dbPath);
const ulong receiver = 99002;
var now = DateTimeOffset.UtcNow;
for (var i = 0; i < 30; i++)
{
var msg = TellMessage("Asuka", 76, receiver, now.AddMinutes(-i - 1), ChatType.TellIncoming);
store.UpsertMessage(msg);
}
var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 5);
Assert.AreEqual(5, result.Count);
}
[TestMethod]
[Timeout(5000)]
public void GetTellHistoryWithSender_ZeroLimitReturnsEmpty()
{
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
var dbPath = Path.Join(tempDir.FullName, "test.db");
TestContext.WriteLine("Using database path: " + dbPath);
using var store = new MessageStore(dbPath);
const ulong receiver = 99003;
var msg = TellMessage("Asuka", 76, receiver, DateTimeOffset.UtcNow, ChatType.TellIncoming);
store.UpsertMessage(msg);
var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 0);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
[Timeout(5000)]
public void GetTellHistoryWithSender_IgnoresOtherReceivers()
{
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
var dbPath = Path.Join(tempDir.FullName, "test.db");
TestContext.WriteLine("Using database path: " + dbPath);
using var store = new MessageStore(dbPath);
const ulong ourReceiver = 99004;
const ulong otherReceiver = 99005;
var now = DateTimeOffset.UtcNow;
// Tell on the local player's account.
var ours = TellMessage("Asuka", 76, ourReceiver, now.AddMinutes(-1), ChatType.TellIncoming);
// Same sender, but logged against a different local character —
// common when the user has alts. Must not surface.
var foreign = TellMessage("Asuka", 76, otherReceiver, now, ChatType.TellIncoming);
store.UpsertMessage(ours);
store.UpsertMessage(foreign);
var result = store.GetTellHistoryWithSender(ourReceiver, "Asuka", 76, limit: 50);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(ours.Id, result[0].Id);
}
private static Message TellMessage(
string senderName,
uint senderWorld,
ulong receiver,
DateTimeOffset dateTime,
ChatType chatType)
{
var senderSeString = new SeStringBuilder()
.Add(new PlayerPayload(senderName, senderWorld))
.AddText(senderName)
.Add(RawPayload.LinkTerminator)
.Build();
var contentSeString = new SeStringBuilder()
.AddText("test message")
.Build();
var senderChunks = ChunkUtil.ToChunks(senderSeString, ChunkSource.Sender, chatType).ToList();
var contentChunks = ChunkUtil.ToChunks(contentSeString, ChunkSource.Content, chatType).ToList();
var chatCode = new ChatCode((XivChatType)chatType, XivChatRelationKind.LocalPlayer, XivChatRelationKind.LocalPlayer);
return new Message(
Guid.NewGuid(),
receiver,
0,
dateTime,
chatCode,
senderChunks,
contentChunks,
senderSeString,
contentSeString,
Guid.Empty);
}
}
+1 -30
View File
@@ -361,41 +361,12 @@ public class Tab
// here, otherwise all temp tabs would mirror "Tell Exclusive".
if (IsTempTab && TellTarget?.IsSet() == true)
{
return MatchesTempTabSender(message);
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
}
return true;
}
private bool MatchesTempTabSender(Message message)
{
var senderPayload = ExtractPlayerPayload(message.Sender);
if (senderPayload == null)
{
senderPayload = ExtractPlayerPayload(message.Content);
}
if (senderPayload == null)
{
return false;
}
var nameMatches = string.Equals(senderPayload.PlayerName, TellTarget.Name, StringComparison.OrdinalIgnoreCase);
var worldMatches = senderPayload.World.RowId == TellTarget.World;
return nameMatches && worldMatches;
}
private static PlayerPayload? ExtractPlayerPayload(IReadOnlyList<Chunk> chunks)
{
foreach (var chunk in chunks)
{
if (chunk.Link is PlayerPayload pp)
{
return pp;
}
}
return null;
}
public void AddMessage(Message message, bool unread = true)
{
Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
+78
View File
@@ -602,6 +602,84 @@ internal class MessageStore : IDisposable
return new MessageEnumerator(cmd.ExecuteReader());
}
/// <summary>
/// Hellion Chat — Auto-Tell-Tabs history preload.
///
/// Returns up to <paramref name="limit"/> tells exchanged with the named
/// player, oldest-first, ready to be added to a freshly spawned auto
/// tell tab. The Sender column is a serialized chunk blob, so SQL on its
/// own cannot filter by player identity; we narrow with SQL on Receiver
/// + ChatType (cheap, indexed) and let the client do the final
/// PlayerPayload comparison on the result set.
///
/// <paramref name="sqlScanLimit"/> caps how many recent tells we scan
/// before giving up. 500 covers around 10 days for an active greeter
/// and stays well under the 20 ms budget required to keep the spawn on
/// the message-processing worker thread.
/// </summary>
internal IReadOnlyList<Message> GetTellHistoryWithSender(
ulong receiver,
string senderName,
uint senderWorld,
int limit,
int sqlScanLimit = 500)
{
if (limit <= 0)
{
return [];
}
using var cmd = Connection.CreateCommand();
cmd.CommandText = @"
SELECT
Id,
Receiver,
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages
WHERE deleted = false
AND Receiver = $Receiver
AND ChatType IN ($TellIncoming, $TellOutgoing)
ORDER BY Date DESC
LIMIT $ScanLimit;
";
cmd.CommandTimeout = 60;
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming);
cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing);
cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit);
var collected = new List<Message>();
using var enumerator = new MessageEnumerator(cmd.ExecuteReader());
foreach (var message in enumerator)
{
if (!ChunkUtil.MatchesSender(message, senderName, senderWorld))
{
continue;
}
collected.Add(message);
if (collected.Count >= limit)
{
break;
}
}
// SQL was DESC (newest-first) so we hit the limit on the most
// recent matching tells. Reverse to oldest-first for chronological
// display in the tab.
collected.Reverse();
return collected;
}
/// <summary>
/// Marks a message as deleted so it won't get returned in queries.
/// </summary>
+34
View File
@@ -398,6 +398,40 @@ internal static class ChunkUtil
return builder.ToString();
}
// Hellion Chat — shared helper for Auto-Tell-Tabs and the MessageStore
// history-preload query. Walks the chunk list once and returns the
// first PlayerPayload it finds, or null when the message has no
// resolved player link (e.g. system messages, GM tells we already
// skipped earlier in the pipeline).
internal static PlayerPayload? TryGetPlayerPayload(IReadOnlyList<Chunk> chunks)
{
foreach (var chunk in chunks)
{
if (chunk.Link is PlayerPayload pp)
{
return pp;
}
}
return null;
}
// True when the message's sender (or, as a fallback, content) carries a
// PlayerPayload that matches the given identity. Used by both the
// Tab.Matches sender filter and the MessageStore tell-history scan.
internal static bool MatchesSender(Message message, string senderName, uint senderWorld)
{
var payload = TryGetPlayerPayload(message.Sender) ?? TryGetPlayerPayload(message.Content);
if (payload == null)
{
return false;
}
if (!string.Equals(payload.PlayerName, senderName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return payload.World.RowId == senderWorld;
}
internal static readonly RawPayload PeriodicRecruitmentLink = new([0x02, 0x27, 0x07, 0x08, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]);
private static uint GetInteger(BinaryReader input)