165 lines
6.0 KiB
C#
165 lines
6.0 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text;
|
||
|
||
namespace WxAgent.Core;
|
||
|
||
public enum ChatMessageType
|
||
{
|
||
Text,
|
||
Image,
|
||
File,
|
||
Video,
|
||
Voice,
|
||
Link,
|
||
Location,
|
||
Emotion,
|
||
Merge,
|
||
PersonalCard,
|
||
Note,
|
||
Other,
|
||
Quote,
|
||
System
|
||
}
|
||
|
||
public sealed record QuotedMessageSnapshot(string? Sender, string Text);
|
||
|
||
public sealed record ChatMessageSource(string Text, string? SourceId = null);
|
||
|
||
public sealed record ChatMessageSnapshot(
|
||
string Text,
|
||
string Fingerprint,
|
||
int VisibleIndex,
|
||
ChatMessageType Type,
|
||
QuotedMessageSnapshot? Quote = null);
|
||
|
||
public static class VisibleMessageParser
|
||
{
|
||
public static IReadOnlyList<ChatMessageSnapshot> Parse(IEnumerable<string> accessibleNames) =>
|
||
Parse(accessibleNames.Select(text => new ChatMessageSource(text)));
|
||
|
||
public static IReadOnlyList<ChatMessageSnapshot> Parse(IEnumerable<ChatMessageSource> sources)
|
||
{
|
||
var occurrences = new Dictionary<string, int>(StringComparer.Ordinal);
|
||
var messages = new List<ChatMessageSnapshot>();
|
||
foreach (var source in sources.Where(value => !string.IsNullOrWhiteSpace(value.Text)))
|
||
{
|
||
var text = source.Text;
|
||
var occurrence = occurrences.TryGetValue(text, out var count) ? count : 0;
|
||
occurrences[text] = occurrence + 1;
|
||
var identity = $"{occurrence}:{text}";
|
||
var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant();
|
||
var (replyText, quote) = ParseQuote(text);
|
||
var type = quote is null ? Classify(replyText) : ChatMessageType.Quote;
|
||
messages.Add(new ChatMessageSnapshot(replyText, fingerprint, messages.Count, type, quote));
|
||
}
|
||
|
||
return messages;
|
||
}
|
||
|
||
public static (string Text, QuotedMessageSnapshot? Quote) ParseQuote(string accessibleName)
|
||
{
|
||
var normalized = accessibleName.ReplaceLineEndings("\n");
|
||
var lastLine = normalized.LastIndexOf('\n');
|
||
if (lastLine >= 0 && normalized[(lastLine + 1)..].StartsWith("引用 ", StringComparison.Ordinal))
|
||
{
|
||
var descriptor = normalized[(lastLine + 1)..];
|
||
var messageMarker = descriptor.IndexOf(" 的消息", StringComparison.Ordinal);
|
||
var separator = descriptor.IndexOfAny([':', ':']);
|
||
if (messageMarker > 3 && separator > messageMarker)
|
||
{
|
||
var sender = descriptor[3..messageMarker].Trim();
|
||
var quoteText = descriptor[(separator + 1)..].Trim();
|
||
return (normalized[..lastLine], new QuotedMessageSnapshot(sender, quoteText));
|
||
}
|
||
}
|
||
|
||
var lines = accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||
var quoteIndex = Array.FindIndex(lines, line =>
|
||
string.Equals(line, "引用", StringComparison.Ordinal) || line.StartsWith("引用:", StringComparison.Ordinal));
|
||
if (quoteIndex < 0 || lines.Length - quoteIndex < 3)
|
||
{
|
||
return (accessibleName, null);
|
||
}
|
||
|
||
var marker = lines[quoteIndex];
|
||
var legacySender = marker.Length > 3 ? marker[3..].Trim() : null;
|
||
var legacyQuoteText = string.Join('\n', lines[(quoteIndex + 1)..^1]);
|
||
return (lines[^1], new QuotedMessageSnapshot(string.IsNullOrWhiteSpace(legacySender) ? null : legacySender, legacyQuoteText));
|
||
}
|
||
|
||
public static ChatMessageType Classify(string text)
|
||
{
|
||
if (text is "[图片]" or "图片" || text.StartsWith("图片\n", StringComparison.Ordinal))
|
||
{
|
||
return ChatMessageType.Image;
|
||
}
|
||
|
||
if (text is "[视频]" or "视频" || text.StartsWith("视频\n", StringComparison.Ordinal))
|
||
{
|
||
return ChatMessageType.Video;
|
||
}
|
||
|
||
if (text is "[语音]" or "语音" || text.StartsWith("语音\n", StringComparison.Ordinal))
|
||
{
|
||
return ChatMessageType.Voice;
|
||
}
|
||
|
||
if (Uri.TryCreate(text, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
|
||
{
|
||
return ChatMessageType.Link;
|
||
}
|
||
|
||
if (text.StartsWith("文件\n", StringComparison.Ordinal))
|
||
{
|
||
return ChatMessageType.File;
|
||
}
|
||
|
||
if (text is "[位置]" or "位置" || text.StartsWith("位置\n", StringComparison.Ordinal)) return ChatMessageType.Location;
|
||
if (text is "[动画表情]" or "动画表情" or "表情") return ChatMessageType.Emotion;
|
||
if (text.Contains("聊天记录", StringComparison.Ordinal)) return ChatMessageType.Merge;
|
||
if (text is "[名片]" or "个人名片" || text.StartsWith("名片\n", StringComparison.Ordinal)) return ChatMessageType.PersonalCard;
|
||
if (text is "[笔记]" or "笔记" || text.StartsWith("笔记\n", StringComparison.Ordinal)) return ChatMessageType.Note;
|
||
|
||
if (!text.Contains(Path.DirectorySeparatorChar) && !text.Contains(Path.AltDirectorySeparatorChar) &&
|
||
Path.GetExtension(text) is { Length: > 1 } extension && extension.Length <= 10)
|
||
{
|
||
return ChatMessageType.File;
|
||
}
|
||
|
||
return text.StartsWith("[", StringComparison.Ordinal) && text.EndsWith("]", StringComparison.Ordinal)
|
||
? ChatMessageType.System
|
||
: ChatMessageType.Text;
|
||
}
|
||
}
|
||
|
||
public sealed class BoundedMessageDeduper
|
||
{
|
||
private readonly int _capacity;
|
||
private readonly Queue<string> _order = new();
|
||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||
|
||
public BoundedMessageDeduper(int capacity = 2048)
|
||
{
|
||
if (capacity <= 0)
|
||
throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be positive.");
|
||
_capacity = capacity;
|
||
}
|
||
|
||
public bool TryAdd(string fingerprint)
|
||
{
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
|
||
if (!_seen.Add(fingerprint))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
_order.Enqueue(fingerprint);
|
||
if (_order.Count > _capacity)
|
||
{
|
||
_seen.Remove(_order.Dequeue());
|
||
}
|
||
|
||
return true;
|
||
}
|
||
}
|