Add per-channel message retention with daily background sweep
Privacy filter trimmed history "by what" — this adds the time axis. Each ChatType gets its own retention window in days; channels without an explicit override fall back to a configurable global default. The master switch defaults to OFF: the plugin never deletes history without explicit user consent. MessageStore.DeleteByRetentionPolicy builds an OR'd WHERE clause over (ChatType = X AND Date < cutoff_X) plus a NOT IN catch-all for the global default, hard-deletes matches, and only runs VACUUM when something was actually removed. Plugin.RunRetentionSweepIfDue runs at most once per 24 hours on a background thread (off the load path) and persists the timestamp so subsequent restarts skip the sweep until enough time has passed. The Privacy tab gains a retention section with the master switch, default-days input, per-channel override tree, reset buttons, and a Ctrl+Shift "apply now" action that mirrors the auto-sweep but on demand. Spec defaults: Tells 365 days, own-conversation channels (Party, Cross-Party, Alliance, PvP Team, FC, Linkshells 1-8, Cross-World Linkshells 1-8, ExtraChat 1-8) 90 days, fallback 30 days.
This commit is contained in:
@@ -54,6 +54,21 @@ public class Configuration : IPluginConfiguration
|
|||||||
return PrivacyPersistUnknownChannels;
|
return PrivacyPersistUnknownChannels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hellion Chat — Message retention (GDPR data minimization, time axis).
|
||||||
|
// Master switch defaults to false; the plugin will not delete history
|
||||||
|
// until the user explicitly opts in.
|
||||||
|
public bool RetentionEnabled;
|
||||||
|
public int RetentionDefaultDays = 30;
|
||||||
|
public Dictionary<ChatType, int> RetentionPerChannelDays = [];
|
||||||
|
public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public int GetRetentionDays(ChatType type)
|
||||||
|
{
|
||||||
|
if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
|
||||||
|
return userOverride;
|
||||||
|
return RetentionDefaultDays;
|
||||||
|
}
|
||||||
|
|
||||||
public bool HideChat = true;
|
public bool HideChat = true;
|
||||||
public bool HideDuringCutscenes = true;
|
public bool HideDuringCutscenes = true;
|
||||||
public bool HideWhenNotLoggedIn = true;
|
public bool HideWhenNotLoggedIn = true;
|
||||||
@@ -216,6 +231,11 @@ public class Configuration : IPluginConfiguration
|
|||||||
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
|
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
|
||||||
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
|
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
|
||||||
PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels;
|
PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels;
|
||||||
|
|
||||||
|
RetentionEnabled = other.RetentionEnabled;
|
||||||
|
RetentionDefaultDays = other.RetentionDefaultDays;
|
||||||
|
RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary(p => p.Key, p => p.Value);
|
||||||
|
RetentionLastRunAt = other.RetentionLastRunAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -331,6 +331,56 @@ internal class MessageStore : IDisposable
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes messages older than the per-channel retention window, with a
|
||||||
|
/// global default for channels not listed explicitly. Cutoffs are
|
||||||
|
/// computed from "now" at call time. Runs VACUUM only if anything was
|
||||||
|
/// removed. Returns the number of rows deleted.
|
||||||
|
/// </summary>
|
||||||
|
internal long DeleteByRetentionPolicy(IReadOnlyDictionary<int, int> chatTypeDaysMap, int defaultDays)
|
||||||
|
{
|
||||||
|
if (defaultDays < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(defaultDays), "Negative retention is not allowed.");
|
||||||
|
foreach (var (_, days) in chatTypeDaysMap)
|
||||||
|
if (days < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(chatTypeDaysMap), "Negative retention is not allowed.");
|
||||||
|
|
||||||
|
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
var clauses = new List<string>();
|
||||||
|
foreach (var (type, days) in chatTypeDaysMap)
|
||||||
|
{
|
||||||
|
var cutoff = nowMs - days * 86400000L;
|
||||||
|
clauses.Add($"(ChatType = {type} AND Date < {cutoff})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch-all for channels without an explicit override. "0" is treated
|
||||||
|
// as "do not delete by default" — without an explicit user override,
|
||||||
|
// unmapped channels stay forever instead of getting wiped immediately.
|
||||||
|
if (defaultDays > 0)
|
||||||
|
{
|
||||||
|
var cutoff = nowMs - defaultDays * 86400000L;
|
||||||
|
var explicitTypes = chatTypeDaysMap.Count > 0
|
||||||
|
? string.Join(",", chatTypeDaysMap.Keys)
|
||||||
|
: "-1"; // empty list would produce invalid SQL
|
||||||
|
clauses.Add($"(ChatType NOT IN ({explicitTypes}) AND Date < {cutoff})");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clauses.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
long deleted;
|
||||||
|
using (var cmd = Connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};";
|
||||||
|
cmd.CommandTimeout = 600;
|
||||||
|
deleted = cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted > 0)
|
||||||
|
PerformMaintenance();
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hard-deletes every message whose ChatType is not in the supplied
|
/// Hard-deletes every message whose ChatType is not in the supplied
|
||||||
/// allowlist, then VACUUMs the database to reclaim disk space.
|
/// allowlist, then VACUUMs the database to reclaim disk space.
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
|
|
||||||
MessageManager = new MessageManager(this); // Does it require UI?
|
MessageManager = new MessageManager(this); // Does it require UI?
|
||||||
|
|
||||||
|
// Hellion Chat — daily retention sweep, off-thread so it never
|
||||||
|
// blocks plugin load. Skips itself when disabled or already ran
|
||||||
|
// within the past 24 hours.
|
||||||
|
RunRetentionSweepIfDue();
|
||||||
|
|
||||||
ChatLogWindow = new ChatLogWindow(this);
|
ChatLogWindow = new ChatLogWindow(this);
|
||||||
SettingsWindow = new SettingsWindow(this);
|
SettingsWindow = new SettingsWindow(this);
|
||||||
DbViewer = new DbViewer(this);
|
DbViewer = new DbViewer(this);
|
||||||
@@ -305,6 +310,46 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RunRetentionSweepIfDue()
|
||||||
|
{
|
||||||
|
if (!Config.RetentionEnabled)
|
||||||
|
return;
|
||||||
|
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Snapshot the policy so the user can edit settings while we run.
|
||||||
|
var policy = Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value);
|
||||||
|
var defaultDays = Config.RetentionDefaultDays;
|
||||||
|
|
||||||
|
new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
|
||||||
|
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
|
||||||
|
SaveConfig();
|
||||||
|
|
||||||
|
if (deleted > 0)
|
||||||
|
{
|
||||||
|
Log.Information($"Retention sweep deleted {deleted} expired messages.");
|
||||||
|
Framework.Run(() =>
|
||||||
|
{
|
||||||
|
MessageManager.ClearAllTabs();
|
||||||
|
MessageManager.FilterAllTabsAsync();
|
||||||
|
}).Wait();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Information("Retention sweep ran, nothing expired.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Log.Error(e, "Retention sweep failed");
|
||||||
|
}
|
||||||
|
}) { IsBackground = true }.Start();
|
||||||
|
}
|
||||||
|
|
||||||
private void Draw()
|
private void Draw()
|
||||||
{
|
{
|
||||||
ChatLogWindow.BeginFrame();
|
ChatLogWindow.BeginFrame();
|
||||||
|
|||||||
@@ -41,4 +41,47 @@ internal static class PrivacyDefaults
|
|||||||
ChatType.ExtraChatLinkshell7,
|
ChatType.ExtraChatLinkshell7,
|
||||||
ChatType.ExtraChatLinkshell8,
|
ChatType.ExtraChatLinkshell8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default retention windows per channel (in days). Channels not listed
|
||||||
|
// here fall back to Configuration.RetentionDefaultDays. Reflects the
|
||||||
|
// design spec: Tells 365, own-conversation channels 90, everything else
|
||||||
|
// shorter via the global default.
|
||||||
|
internal static readonly IReadOnlyDictionary<ChatType, int> DefaultRetentionDays = new Dictionary<ChatType, int>
|
||||||
|
{
|
||||||
|
[ChatType.TellIncoming] = 365,
|
||||||
|
[ChatType.TellOutgoing] = 365,
|
||||||
|
|
||||||
|
[ChatType.Party] = 90,
|
||||||
|
[ChatType.CrossParty] = 90,
|
||||||
|
[ChatType.Alliance] = 90,
|
||||||
|
[ChatType.PvpTeam] = 90,
|
||||||
|
[ChatType.FreeCompany] = 90,
|
||||||
|
|
||||||
|
[ChatType.Linkshell1] = 90,
|
||||||
|
[ChatType.Linkshell2] = 90,
|
||||||
|
[ChatType.Linkshell3] = 90,
|
||||||
|
[ChatType.Linkshell4] = 90,
|
||||||
|
[ChatType.Linkshell5] = 90,
|
||||||
|
[ChatType.Linkshell6] = 90,
|
||||||
|
[ChatType.Linkshell7] = 90,
|
||||||
|
[ChatType.Linkshell8] = 90,
|
||||||
|
|
||||||
|
[ChatType.CrossLinkshell1] = 90,
|
||||||
|
[ChatType.CrossLinkshell2] = 90,
|
||||||
|
[ChatType.CrossLinkshell3] = 90,
|
||||||
|
[ChatType.CrossLinkshell4] = 90,
|
||||||
|
[ChatType.CrossLinkshell5] = 90,
|
||||||
|
[ChatType.CrossLinkshell6] = 90,
|
||||||
|
[ChatType.CrossLinkshell7] = 90,
|
||||||
|
[ChatType.CrossLinkshell8] = 90,
|
||||||
|
|
||||||
|
[ChatType.ExtraChatLinkshell1] = 90,
|
||||||
|
[ChatType.ExtraChatLinkshell2] = 90,
|
||||||
|
[ChatType.ExtraChatLinkshell3] = 90,
|
||||||
|
[ChatType.ExtraChatLinkshell4] = 90,
|
||||||
|
[ChatType.ExtraChatLinkshell5] = 90,
|
||||||
|
[ChatType.ExtraChatLinkshell6] = 90,
|
||||||
|
[ChatType.ExtraChatLinkshell7] = 90,
|
||||||
|
[ChatType.ExtraChatLinkshell8] = 90,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ internal sealed class Privacy : ISettingsTab
|
|||||||
private long CleanupDeleteCount;
|
private long CleanupDeleteCount;
|
||||||
private bool CleanupRunning;
|
private bool CleanupRunning;
|
||||||
|
|
||||||
|
private bool RetentionRunning;
|
||||||
|
|
||||||
public void Draw(bool changed)
|
public void Draw(bool changed)
|
||||||
{
|
{
|
||||||
ImGuiUtil.OptionCheckbox(
|
ImGuiUtil.OptionCheckbox(
|
||||||
@@ -131,9 +133,146 @@ internal sealed class Privacy : ISettingsTab
|
|||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
ImGui.Spacing();
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
DrawRetentionSection();
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.Separator();
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
DrawCleanupSection();
|
DrawCleanupSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawRetentionSection()
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("Message retention");
|
||||||
|
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||||
|
{
|
||||||
|
ImGuiUtil.OptionCheckbox(
|
||||||
|
ref Mutable.RetentionEnabled,
|
||||||
|
"Auto-delete messages after a per-channel retention window",
|
||||||
|
"When enabled, messages older than the configured window are deleted on every plugin start (at most once per 24 hours). " +
|
||||||
|
"Off by default — the plugin never deletes history without your explicit consent.");
|
||||||
|
|
||||||
|
using (ImRaii.Disabled(!Mutable.RetentionEnabled))
|
||||||
|
{
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
var defaultDays = Mutable.RetentionDefaultDays;
|
||||||
|
if (ImGui.InputInt("Default retention (days, 0 = never)", ref defaultDays))
|
||||||
|
Mutable.RetentionDefaultDays = Math.Max(0, defaultDays);
|
||||||
|
ImGuiUtil.HelpText("Applies to channels without an explicit override below.");
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
if (ImGui.Button("Reset overrides to spec defaults"))
|
||||||
|
{
|
||||||
|
Mutable.RetentionPerChannelDays =
|
||||||
|
PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value);
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Clear all overrides"))
|
||||||
|
Mutable.RetentionPerChannelDays.Clear();
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
using (var tree = ImRaii.TreeNode("Per-channel retention overrides"))
|
||||||
|
{
|
||||||
|
if (tree.Success)
|
||||||
|
{
|
||||||
|
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||||
|
foreach (var (heading, types) in Groups)
|
||||||
|
{
|
||||||
|
using var subTree = ImRaii.TreeNode(heading);
|
||||||
|
if (!subTree.Success)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
var hasOverride = Mutable.RetentionPerChannelDays.TryGetValue(type, out var days);
|
||||||
|
if (!hasOverride)
|
||||||
|
days = Mutable.RetentionDefaultDays;
|
||||||
|
|
||||||
|
if (ImGui.InputInt($"{type}##retention-{(int)type}", ref days))
|
||||||
|
{
|
||||||
|
days = Math.Max(0, days);
|
||||||
|
Mutable.RetentionPerChannelDays[type] = days;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOverride)
|
||||||
|
{
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button($"reset##retention-reset-{(int)type}"))
|
||||||
|
Mutable.RetentionPerChannelDays.Remove(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
using (ImRaii.Disabled(RetentionRunning))
|
||||||
|
{
|
||||||
|
if (ImGuiUtil.CtrlShiftButton("Apply retention policy now",
|
||||||
|
"Ctrl+Shift: runs the retention sweep immediately using the SAVED policy. Save your changes first."))
|
||||||
|
StartRetentionRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RetentionRunning)
|
||||||
|
ImGuiUtil.HelpText("Retention sweep running in background…");
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
var lastRun = Plugin.Config.RetentionLastRunAt;
|
||||||
|
ImGuiUtil.HelpText(lastRun == DateTimeOffset.MinValue
|
||||||
|
? "Last run: never"
|
||||||
|
: $"Last run: {lastRun.ToLocalTime():yyyy-MM-dd HH:mm}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartRetentionRun()
|
||||||
|
{
|
||||||
|
if (RetentionRunning)
|
||||||
|
return;
|
||||||
|
|
||||||
|
RetentionRunning = true;
|
||||||
|
var policy = Plugin.Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value);
|
||||||
|
var defaultDays = Plugin.Config.RetentionDefaultDays;
|
||||||
|
|
||||||
|
new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deleted = Plugin.MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
|
||||||
|
Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
|
||||||
|
Plugin.SaveConfig();
|
||||||
|
|
||||||
|
Plugin.Log.Information($"Manual retention run deleted {deleted} expired messages.");
|
||||||
|
|
||||||
|
if (deleted > 0)
|
||||||
|
{
|
||||||
|
Plugin.Framework.Run(() =>
|
||||||
|
{
|
||||||
|
Plugin.MessageManager.ClearAllTabs();
|
||||||
|
Plugin.MessageManager.FilterAllTabsAsync();
|
||||||
|
}).Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
WrapperUtil.AddNotification($"Retention sweep complete: {deleted:N0} messages removed.", NotificationType.Success);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Plugin.Log.Error(e, "Manual retention run failed");
|
||||||
|
WrapperUtil.AddNotification("Retention sweep failed, see /xllog", NotificationType.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RetentionRunning = false;
|
||||||
|
}
|
||||||
|
}) { IsBackground = true }.Start();
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawCleanupSection()
|
private void DrawCleanupSection()
|
||||||
{
|
{
|
||||||
ImGui.TextUnformatted("Apply filter to existing database");
|
ImGui.TextUnformatted("Apply filter to existing database");
|
||||||
|
|||||||
Reference in New Issue
Block a user