sigma update

This commit is contained in:
2026-01-16 11:00:58 +09:00
parent 59ed03a825
commit 96123d00a2
51 changed files with 6640 additions and 1382 deletions

View File

@@ -8,18 +8,26 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using LightlessSync.UI.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LightlessSync.Services.Chat;
public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService
{
private const int MaxMessageHistory = 150;
private const int MaxMessageHistory = 200;
internal const int MaxOutgoingLength = 200;
private const int MaxUnreadCount = 999;
private const string ZoneUnavailableMessage = "Zone chat is only available in major cities.";
private const string ZoneChannelKey = "zone";
private const int MaxReportReasonLength = 100;
private const int MaxReportContextLength = 1000;
private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtilService;
@@ -376,6 +384,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
public Task StartAsync(CancellationToken cancellationToken)
{
LoadPersistedSyncshellHistory();
Mediator.Subscribe<DalamudLoginMessage>(this, _ => HandleLogin());
Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ScheduleZonePresenceUpdate());
@@ -1000,11 +1009,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
private void OnChatMessageReceived(ChatMessageDto dto)
{
var descriptor = dto.Channel.WithNormalizedCustomKey();
var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
var fromSelf = IsMessageFromSelf(dto, key);
var message = BuildMessage(dto, fromSelf);
ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey();
string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
bool fromSelf = IsMessageFromSelf(dto, key);
ChatMessageEntry message = BuildMessage(dto, fromSelf);
bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications;
bool notifyMention = mentionNotificationsEnabled
&& !fromSelf
&& descriptor.Type == ChatChannelType.Group
&& TryGetSelfMentionToken(dto.Message, out _);
string? mentionChannelName = null;
string? mentionSenderName = null;
bool publishChannelList = false;
bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory;
List<PersistedChatMessage>? persistedMessages = null;
string? persistedChannelKey = null;
using (_sync.EnterScope())
{
@@ -1042,6 +1062,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.Messages.RemoveAt(0);
}
if (notifyMention)
{
mentionChannelName = state.DisplayName;
mentionSenderName = message.DisplayName;
}
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
{
state.HasUnread = false;
@@ -1058,10 +1084,29 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
}
MarkChannelsSnapshotDirtyLocked();
if (shouldPersistHistory && state.Type == ChatChannelType.Group)
{
persistedChannelKey = state.Key;
persistedMessages = BuildPersistedHistoryLocked(state);
}
}
Mediator.Publish(new ChatChannelMessageAdded(key, message));
if (persistedMessages is not null && persistedChannelKey is not null)
{
PersistSyncshellHistory(persistedChannelKey, persistedMessages);
}
if (notifyMention)
{
string channelName = mentionChannelName ?? "Syncshell";
string senderName = mentionSenderName ?? "Someone";
string notificationText = $"You were mentioned by {senderName} in {channelName}.";
Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info));
}
if (publishChannelList)
{
using (_sync.EnterScope())
@@ -1108,6 +1153,113 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return false;
}
private bool TryGetSelfMentionToken(string message, out string matchedToken)
{
matchedToken = string.Empty;
if (string.IsNullOrWhiteSpace(message))
{
return false;
}
HashSet<string> tokens = BuildSelfMentionTokens();
if (tokens.Count == 0)
{
return false;
}
return TryFindMentionToken(message, tokens, out matchedToken);
}
private HashSet<string> BuildSelfMentionTokens()
{
HashSet<string> tokens = new(StringComparer.OrdinalIgnoreCase);
string uid = _apiController.UID;
if (IsValidMentionToken(uid))
{
tokens.Add(uid);
}
string displayName = _apiController.DisplayName;
if (IsValidMentionToken(displayName))
{
tokens.Add(displayName);
}
return tokens;
}
private static bool IsValidMentionToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
for (int i = 0; i < value.Length; i++)
{
if (!IsMentionChar(value[i]))
{
return false;
}
}
return true;
}
private static bool TryFindMentionToken(string message, IReadOnlyCollection<string> tokens, out string matchedToken)
{
matchedToken = string.Empty;
if (tokens.Count == 0 || string.IsNullOrEmpty(message))
{
return false;
}
int index = 0;
while (index < message.Length)
{
if (message[index] != '@')
{
index++;
continue;
}
if (index > 0 && IsMentionChar(message[index - 1]))
{
index++;
continue;
}
int start = index + 1;
int end = start;
while (end < message.Length && IsMentionChar(message[end]))
{
end++;
}
if (end == start)
{
index++;
continue;
}
string token = message.Substring(start, end - start);
if (tokens.Contains(token))
{
matchedToken = token;
return true;
}
index = end;
}
return false;
}
private static bool IsMentionChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\'';
}
private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf)
{
var displayName = ResolveDisplayName(dto, fromSelf);
@@ -1364,6 +1516,313 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return 0;
}
private void LoadPersistedSyncshellHistory()
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count == 0)
{
return;
}
List<string> invalidKeys = new();
foreach (KeyValuePair<string, string> entry in persisted)
{
if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))
{
invalidKeys.Add(entry.Key);
continue;
}
if (!TryDecodePersistedHistory(entry.Value, out List<PersistedChatMessage> persistedMessages))
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count > MaxMessageHistory)
{
int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory);
persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex);
}
List<ChatMessageEntry> restoredMessages = new(persistedMessages.Count);
foreach (PersistedChatMessage persistedMessage in persistedMessages)
{
if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage))
{
continue;
}
restoredMessages.Add(restoredMessage);
}
if (restoredMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
using (_sync.EnterScope())
{
_messageHistoryCache[entry.Key] = restoredMessages;
}
}
if (invalidKeys.Count > 0)
{
foreach (string key in invalidKeys)
{
persisted.Remove(key);
}
_chatConfigService.Save();
}
}
private List<PersistedChatMessage> BuildPersistedHistoryLocked(ChatChannelState state)
{
int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory);
List<PersistedChatMessage> persistedMessages = new(state.Messages.Count - startIndex);
for (int i = startIndex; i < state.Messages.Count; i++)
{
ChatMessageEntry entry = state.Messages[i];
if (entry.Payload is not { } payload)
{
continue;
}
persistedMessages.Add(new PersistedChatMessage(
payload.Message,
entry.DisplayName,
entry.FromSelf,
entry.ReceivedAtUtc,
payload.SentAtUtc));
}
return persistedMessages;
}
private void PersistSyncshellHistory(string channelKey, List<PersistedChatMessage> persistedMessages)
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persistedMessages.Count == 0)
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
string? base64 = EncodePersistedMessages(persistedMessages);
if (string.IsNullOrWhiteSpace(base64))
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
persisted[channelKey] = base64;
_chatConfigService.Save();
}
private static string? EncodePersistedMessages(List<PersistedChatMessage> persistedMessages)
{
if (persistedMessages.Count == 0)
{
return null;
}
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions);
return Convert.ToBase64String(jsonBytes);
}
private static bool TryDecodePersistedHistory(string base64, out List<PersistedChatMessage> persistedMessages)
{
persistedMessages = new List<PersistedChatMessage>();
if (string.IsNullOrWhiteSpace(base64))
{
return false;
}
try
{
byte[] jsonBytes = Convert.FromBase64String(base64);
List<PersistedChatMessage>? decoded = JsonSerializer.Deserialize<List<PersistedChatMessage>>(jsonBytes, PersistedHistorySerializerOptions);
if (decoded is null)
{
return false;
}
persistedMessages = decoded;
return true;
}
catch
{
return false;
}
}
private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage)
{
restoredMessage = default;
string messageText = persistedMessage.Message;
DateTime sentAtUtc = persistedMessage.SentAtUtc;
if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy)
{
messageText = legacy.Message;
sentAtUtc = legacy.SentAtUtc;
}
if (string.IsNullOrWhiteSpace(messageText))
{
return false;
}
ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey);
ChatSenderDescriptor sender = new ChatSenderDescriptor(
ChatSenderKind.Anonymous,
string.Empty,
null,
null,
null,
false);
ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty);
restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc);
return true;
}
private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey)
{
if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Zone };
}
int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1)
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string typeValue = channelKey[..separatorIndex];
if (!int.TryParse(typeValue, out int parsedType))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string customKey = channelKey[(separatorIndex + 1)..];
ChatChannelType channelType = parsedType switch
{
(int)ChatChannelType.Zone => ChatChannelType.Zone,
(int)ChatChannelType.Group => ChatChannelType.Group,
_ => ChatChannelType.Group
};
return new ChatChannelDescriptor
{
Type = channelType,
CustomKey = customKey
};
}
public void ClearPersistedSyncshellHistory(bool clearLoadedMessages)
{
bool shouldPublish = false;
bool saveConfig = false;
using (_sync.EnterScope())
{
Dictionary<string, List<ChatMessageEntry>> cache = _messageHistoryCache;
if (cache.Count > 0)
{
List<string> keysToRemove = new();
foreach (string key in cache.Keys)
{
if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal))
{
keysToRemove.Add(key);
}
}
foreach (string key in keysToRemove)
{
cache.Remove(key);
}
if (keysToRemove.Count > 0)
{
shouldPublish = true;
}
}
if (clearLoadedMessages)
{
foreach (ChatChannelState state in _channels.Values)
{
if (state.Type != ChatChannelType.Group)
{
continue;
}
if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread)
{
continue;
}
state.Messages.Clear();
state.HasUnread = false;
state.UnreadCount = 0;
_lastReadCounts[state.Key] = 0;
shouldPublish = true;
}
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count > 0)
{
persisted.Clear();
saveConfig = true;
}
if (shouldPublish)
{
MarkChannelsSnapshotDirtyLocked();
}
}
if (saveConfig)
{
_chatConfigService.Save();
}
if (shouldPublish)
{
PublishChannelListChanged();
}
}
private sealed class ChatChannelState
{
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
@@ -1400,4 +1859,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
bool IsOwner);
private readonly record struct PendingSelfMessage(string ChannelKey, string Message);
public sealed record PersistedChatMessage(
string Message = "",
string DisplayName = "",
bool FromSelf = false,
DateTime ReceivedAtUtc = default,
DateTime SentAtUtc = default,
[property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null);
}