feat: implement M5 moments and rich messages

This commit is contained in:
2026-09-04 23:48:28 +08:00
parent 77e1e9dc2b
commit 1bd0344e8b
7 changed files with 806 additions and 0 deletions
+12
View File
@@ -11,6 +11,12 @@ public enum ChatMessageType
Video,
Voice,
Link,
Location,
Emotion,
Merge,
PersonalCard,
Note,
Other,
Quote,
System
}
@@ -106,6 +112,12 @@ public static class VisibleMessageParser
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)
{
+97
View File
@@ -0,0 +1,97 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace WxAgent.Core;
public sealed record WechatMomentSnapshot(
string Author,
string Text,
string Fingerprint,
string? Time = null,
int ImageCount = 0,
bool IsLiked = false);
public sealed record WechatMessageContent(
ChatMessageType Type,
string Text,
Uri? Url = null,
QuotedMessageSnapshot? Quote = null);
public sealed record WechatNoteContentPart(string Kind, string Value);
public sealed record WechatNoteContent(IReadOnlyList<WechatNoteContentPart> Parts)
{
public string ToMarkdown() => string.Join(
Environment.NewLine + Environment.NewLine,
Parts.Select(part => part.Kind == "file" ? $"[{Path.GetFileName(part.Value)}]({part.Value})" : part.Value));
}
public enum WechatMomentPrivacy
{
Public,
TagWhitelist,
TagBlacklist
}
public sealed record WechatMomentPublishOptions(
WechatMomentPrivacy Privacy = WechatMomentPrivacy.Public,
IReadOnlyList<string>? Tags = null)
{
public void Validate()
{
if (!Enum.IsDefined(Privacy))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown moment privacy mode.");
if (Privacy != WechatMomentPrivacy.Public)
WechatOperationPolicy.ValidateNames(Tags ?? [], nameof(Tags));
}
}
public static partial class WechatMessageContentParser
{
[GeneratedRegex(@"https?://[^\s<>\]\[\))]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex UrlRegex();
public static Uri? GetUrl(ChatMessageSnapshot message)
{
ArgumentNullException.ThrowIfNull(message);
var match = UrlRegex().Match(message.Text);
return match.Success && Uri.TryCreate(match.Value.TrimEnd('.', ',', '。', ','), UriKind.Absolute, out var uri) ? uri : null;
}
public static WechatMessageContent GetContent(ChatMessageSnapshot message)
{
ArgumentNullException.ThrowIfNull(message);
return new WechatMessageContent(message.Type, message.Text, GetUrl(message), message.Quote);
}
public static string ToMarkdown(ChatMessageSnapshot message)
{
ArgumentNullException.ThrowIfNull(message);
var content = GetContent(message);
var body = content.Type switch
{
ChatMessageType.Image => $"![图片]({content.Url?.AbsoluteUri ?? string.Empty})",
ChatMessageType.Video => $"[视频]({content.Url?.AbsoluteUri ?? string.Empty})",
ChatMessageType.File => $"[文件]({content.Url?.AbsoluteUri ?? string.Empty})",
ChatMessageType.Voice => $"> 语音:{content.Text}",
ChatMessageType.Link when content.Url is not null => $"[{LinkTitle(content.Text, content.Url)}]({content.Url.AbsoluteUri})",
_ => content.Text
};
if (content.Quote is null) return body;
var quote = string.Join(Environment.NewLine, content.Quote.Text.Split('\n').Select(line => $"> {line}"));
return $"{quote}{Environment.NewLine}{Environment.NewLine}{body}";
}
public static string MomentFingerprint(string author, string text, string? time)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{author}\n{text}\n{time}"));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string LinkTitle(string text, Uri url)
{
var title = UrlRegex().Replace(text, string.Empty, 1).Trim();
return string.IsNullOrWhiteSpace(title) ? url.Host : title;
}
}