feat(auto-tell-tabs): add GetTellHistoryWithSender query and ChunkUtil sender helper
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,41 +361,12 @@ public class Tab
|
|||||||
// here, otherwise all temp tabs would mirror "Tell Exclusive".
|
// here, otherwise all temp tabs would mirror "Tell Exclusive".
|
||||||
if (IsTempTab && TellTarget?.IsSet() == true)
|
if (IsTempTab && TellTarget?.IsSet() == true)
|
||||||
{
|
{
|
||||||
return MatchesTempTabSender(message);
|
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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)
|
public void AddMessage(Message message, bool unread = true)
|
||||||
{
|
{
|
||||||
Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
|
Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
|
||||||
|
|||||||
@@ -602,6 +602,84 @@ internal class MessageStore : IDisposable
|
|||||||
return new MessageEnumerator(cmd.ExecuteReader());
|
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>
|
/// <summary>
|
||||||
/// Marks a message as deleted so it won't get returned in queries.
|
/// Marks a message as deleted so it won't get returned in queries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -398,6 +398,40 @@ internal static class ChunkUtil
|
|||||||
return builder.ToString();
|
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]);
|
internal static readonly RawPayload PeriodicRecruitmentLink = new([0x02, 0x27, 0x07, 0x08, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]);
|
||||||
|
|
||||||
private static uint GetInteger(BinaryReader input)
|
private static uint GetInteger(BinaryReader input)
|
||||||
|
|||||||
Reference in New Issue
Block a user