This commit is contained in:
2026-09-07 13:53:28 +08:00
parent 30df7ae433
commit 6077abd1ad
19 changed files with 983 additions and 245 deletions
+41
View File
@@ -0,0 +1,41 @@
namespace WxAgent.Core;
public sealed record WechatDatabaseContact(string Username, string? NickName, string? Remark, string? AvatarUrl)
{
public string DisplayName => !string.IsNullOrWhiteSpace(Remark) ? Remark :
!string.IsNullOrWhiteSpace(NickName) ? NickName : Username;
}
public sealed record WechatContactPage(IReadOnlyList<WechatDatabaseContact> Contacts, int? NextOffset)
{
public bool HasMore => NextOffset is not null;
}
public static class WechatContactQuery
{
// Stable IDs, not display names, define ordering and identity. No UI fallback.
public const string Sql = """
SELECT username, nick_name, remark, small_head_url FROM contact
WHERE ($contains IS NULL OR instr(username, $contains) > 0
OR instr(nick_name, $contains) > 0 OR instr(remark, $contains) > 0)
AND ($groups IS NULL OR (substr(username, -9) = '@chatroom') = $groups)
ORDER BY username COLLATE BINARY LIMIT $limit OFFSET $offset;
""";
public static IReadOnlyList<KeyValuePair<string, object?>> Parameters(int limit, int offset, string? contains, bool? groupsOnly)
{
if (limit is < 1 or > 10000 || offset < 0 || offset > int.MaxValue - limit)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Contact limit must be 1-10000 and offset must leave room for the next page.");
return new KeyValuePair<string, object?>[]
{
new("$limit", limit + 1), new("$offset", offset), new("$contains", contains),
new("$groups", groupsOnly is null ? null : groupsOnly.Value ? 1 : 0)
};
}
public static WechatContactPage Page(IReadOnlyList<WechatDatabaseContact> rows, int limit, int offset)
{
_ = Parameters(limit, offset, null, null);
return new(rows.Take(limit).ToArray(), rows.Count > limit ? offset + limit : null);
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace WxAgent.Core;
public static class WechatDerivedText
{
// Only consume new text inside the bound message, never an entire window or an unbound OCR popup.
public static string? AddedText(IReadOnlyList<string> before, IReadOnlyList<string> after)
{
var remaining = before.Where(value => !string.IsNullOrWhiteSpace(value))
.GroupBy(value => value, StringComparer.Ordinal).ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
var added = new List<string>();
foreach (var value in after.Where(value => !string.IsNullOrWhiteSpace(value)))
{
if (remaining.GetValueOrDefault(value) > 0) remaining[value]--;
else added.Add(value);
}
if (remaining.Values.Any(count => count > 0) || added.Count == 0) return null;
if (added.Any(value => value.Contains("识别中", StringComparison.Ordinal) || value.Contains("转换中", StringComparison.Ordinal)
|| value.Contains("转换失败", StringComparison.Ordinal) || value.Contains("识别失败", StringComparison.Ordinal)
|| value.Contains("未识别到", StringComparison.Ordinal) || value.Contains("无法转换", StringComparison.Ordinal))) return null;
return string.Join(Environment.NewLine, added);
}
}
@@ -0,0 +1,38 @@
namespace WxAgent.Core;
public sealed record WechatDatabaseGroupMember(long MemberId, string Username, string DisplayName, bool IsOwner);
public sealed record WechatGroupMemberPage(string GroupUsername, IReadOnlyList<WechatDatabaseGroupMember> Members, int? NextOffset)
{
public bool HasMore => NextOffset is not null;
}
public static class WechatGroupMemberQuery
{
public const string Sql = """
SELECT m.member_id, c.username, c.nick_name, c.remark, cr.owner
FROM chat_room AS cr
JOIN chatroom_member AS m ON m.room_id = cr.id
JOIN contact AS c ON c.id = m.member_id
WHERE cr.username = $group
ORDER BY m.member_id LIMIT $limit OFFSET $offset;
""";
public static IReadOnlyList<KeyValuePair<string, object?>> Parameters(string groupUsername, int limit, int offset)
{
if (string.IsNullOrWhiteSpace(groupUsername) || !groupUsername.EndsWith("@chatroom", StringComparison.Ordinal))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A stable @chatroom username is required.");
if (limit is < 1 or > 10000 || offset < 0 || offset > int.MaxValue - limit)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Member limit must be 1-10000 and offset must leave room for the next page.");
return new KeyValuePair<string, object?>[]
{
new("$group", groupUsername), new("$limit", limit + 1), new("$offset", offset)
};
}
public static WechatGroupMemberPage Page(string groupUsername, IReadOnlyList<WechatDatabaseGroupMember> rows, int limit, int offset)
{
_ = Parameters(groupUsername, limit, offset);
return new(groupUsername, rows.Take(limit).ToArray(), rows.Count > limit ? offset + limit : null);
}
}
+18 -3
View File
@@ -6,7 +6,8 @@ public sealed record WechatContactSnapshot(
IReadOnlyList<string>? Tags = null,
string? Signature = null,
string? Source = null,
int? CommonGroupCount = null);
int? CommonGroupCount = null,
string? Username = null);
public sealed record WechatNewFriendSnapshot(string DisplayName, string Status);
@@ -19,6 +20,9 @@ public sealed record WechatAccountSnapshot(string DisplayName, string? WechatId
public sealed record WechatOperationResult(bool Success, WxAgentErrorCode? Code, string Message)
{
public static WechatOperationResult Ok(string message = "ok") => new(true, null, message);
public static WechatOperationResult Unconfirmed(string operation) => new(false, WxAgentErrorCode.ResultUnconfirmed,
$"{operation} was submitted but its business result was not verified. Do not retry automatically.");
}
public static class WechatOperationPolicy
@@ -38,9 +42,20 @@ public static class WechatOperationPolicy
public static void ValidateNames(IEnumerable<string> names, string parameterName)
{
ArgumentNullException.ThrowIfNull(names);
if (!names.Any() || names.Any(string.IsNullOrWhiteSpace))
var values = names.ToArray();
if (values.Length == 0 || values.Any(string.IsNullOrWhiteSpace) ||
values.Distinct(StringComparer.Ordinal).Count() != values.Length)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{parameterName} must contain at least one non-empty name.");
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{parameterName} must contain unique, non-empty names.");
}
}
public static T RequireUnique<T>(IEnumerable<T> candidates, string controlDescription)
{
var matches = candidates.Take(2).ToArray();
if (matches.Length != 1)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound,
$"A unique {controlDescription} was not found; no target was selected.");
return matches[0];
}
}
+2 -1
View File
@@ -19,7 +19,8 @@ public enum WxAgentErrorCode
DatabaseKeyNotFound,
DatabaseOpenFailed,
ClipboardUnavailable,
InvalidArgument
InvalidArgument,
FeatureUnavailable
}
public sealed class WxAgentException(WxAgentErrorCode code, string message, Exception? innerException = null)