diff --git a/node-agent/WxAgent.Host/WindowsAgentBackend.cs b/node-agent/WxAgent.Host/WindowsAgentBackend.cs index bdb233c..7693eea 100644 --- a/node-agent/WxAgent.Host/WindowsAgentBackend.cs +++ b/node-agent/WxAgent.Host/WindowsAgentBackend.cs @@ -26,8 +26,10 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"), new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"), new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"), - new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"), - new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"), + new("db-messages", true, true, true, false, false, "read", false, 30, null, + ["docs/validation/WxAgent-会话消息同步-P4-验收记录.md", "docs/validation/WxAgent-会话消息同步-P4-live-evidence.json"], "4.1.13.65"), + new("db-merged", true, true, true, false, false, "read", false, 30, null, + ["docs/validation/WxAgent-会话消息同步-P4-验收记录.md", "docs/validation/WxAgent-会话消息同步-P4-live-evidence.json"], "4.1.13.65"), new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"), new("listener-events", true, listenerEventsEnabled, listenerEventsEnabled, true, false, "read", false, 30, listenerEventsEnabled ? null : "Background UI listener is disabled; enable it explicitly in the Tray settings window.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"), diff --git a/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs b/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs index 696f1df..664f2f1 100644 --- a/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs +++ b/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs @@ -51,18 +51,19 @@ public sealed class DatabaseMessageSyncCollector : IRemoteDataCollector var chatIds = chatScopes.Select(scope => scope.ChatId).Distinct(StringComparer.Ordinal).ToArray(); var page = await WechatMessageDbReader.ReadIncrementalAsync( account.AccountRootPath, account.Databases, chatIds, cursors, perChatLimit, cancellationToken, overlapRows).ConfigureAwait(false); + var directory = await WechatSessionDbReader.ReadAuthorizedAsync(account, chatScopes, cancellationToken).ConfigureAwait(false); var scopeByChat = chatScopes .GroupBy(scope => scope.ChatId, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); var observedAt = DateTimeOffset.UtcNow; - var conversations = chatScopes.Select(scope => new RemoteSyncConversation( - scope.ChatId, - scope.ChatType, - scope.ChatId, - null, - "authorized-db-scope", + var conversations = directory.Conversations.Select(entry => new RemoteSyncConversation( + entry.ChatId, + entry.ChatType, + entry.Title, + entry.LastActivityAt, + "session/session.db/SessionTable", observedAt, - "observed")).ToArray(); + entry.DirectoryState)).ToArray(); var messages = page.Messages.Select(message => { var scope = scopeByChat.GetValueOrDefault(message.ChatId) @@ -85,7 +86,18 @@ public sealed class DatabaseMessageSyncCollector : IRemoteDataCollector account.WechatVersion ?? "unknown", HashMessage(messageId, text, message.Timestamp)); }).ToArray(); - return new RemoteDataCollection(sourceGeneration, page.NextCursors, conversations, messages, page.IsComplete, page.NewMessageCount > 0, page.UnavailableDatabases); + var nextCursors = new Dictionary(page.NextCursors, StringComparer.Ordinal) + { + [WechatSessionDbReader.DirectoryCursorKey] = directory.DirectoryCursor + }; + var directoryChanged = directory.DirectoryCursor != cursors.GetValueOrDefault(WechatSessionDbReader.DirectoryCursorKey); + var unavailable = page.UnavailableDatabases + .Concat(directory.UnavailableDatabases) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(item => item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + return new RemoteDataCollection(sourceGeneration, nextCursors, conversations, messages, + page.IsComplete && directory.IsComplete, page.NewMessageCount > 0 || directoryChanged, unavailable); } private static string HashMessage(string messageId, string text, DateTimeOffset sourceTime) diff --git a/node-agent/WxAgent.Windows/WechatContactDbReader.cs b/node-agent/WxAgent.Windows/WechatContactDbReader.cs index a690458..002d307 100644 --- a/node-agent/WxAgent.Windows/WechatContactDbReader.cs +++ b/node-agent/WxAgent.Windows/WechatContactDbReader.cs @@ -59,6 +59,40 @@ public static class WechatContactDbReader return WechatContactQuery.Page(contacts, limit, offset); } + public static async Task> ReadDisplayNamesAsync( + AccountKeySet account, IReadOnlyList usernames, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(account); + ArgumentNullException.ThrowIfNull(usernames); + var distinct = usernames.Where(username => !string.IsNullOrWhiteSpace(username)) + .Distinct(StringComparer.Ordinal).ToArray(); + if (distinct.Length == 0) return new Dictionary(StringComparer.Ordinal); + + var database = RequireContactDatabase(account); + var result = new Dictionary(StringComparer.Ordinal); + var path = ContactDatabasePath(account); + foreach (var chunk in distinct.Chunk(400)) + { + cancellationToken.ThrowIfCancellationRequested(); + var placeholders = string.Join(", ", chunk.Select((_, index) => $"$p{index}")); + var parameters = chunk.Select((username, index) => + new KeyValuePair($"$p{index}", username)).ToArray(); + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(path, database.EncKey, + $"SELECT username, nick_name, remark FROM contact WHERE username IN ({placeholders});", + parameters, cancellationToken).ConfigureAwait(false); + foreach (var row in rows) + { + var username = row.GetValueOrDefault("username") as string; + if (string.IsNullOrWhiteSpace(username)) continue; + var remark = row.GetValueOrDefault("remark") as string; + var nickname = row.GetValueOrDefault("nick_name") as string; + var displayName = !string.IsNullOrWhiteSpace(remark) ? remark : nickname; + if (!string.IsNullOrWhiteSpace(displayName)) result[username] = displayName; + } + } + return result; + } + public static async Task ReadGroupMembersPageAsync(AccountKeySet account, string group, int limit = 500, int offset = 0, CancellationToken cancellationToken = default) { diff --git a/node-agent/WxAgent.Windows/WechatSessionDbReader.cs b/node-agent/WxAgent.Windows/WechatSessionDbReader.cs new file mode 100644 index 0000000..e5b9ee7 --- /dev/null +++ b/node-agent/WxAgent.Windows/WechatSessionDbReader.cs @@ -0,0 +1,131 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record DbConversationDirectoryEntry( + string ChatId, + ReportingChatType ChatType, + string Title, + DateTimeOffset? LastActivityAt, + string DirectoryState); + +public sealed record DbConversationDirectoryPage( + IReadOnlyList Conversations, + IReadOnlyList UnavailableDatabases, + bool IsComplete, + long DirectoryCursor); + +/// +/// Reads the real WeChat session directory before applying reporting scopes. +/// Scope configuration is only a filter; it never creates a conversation row. +/// +public static class WechatSessionDbReader +{ + public const string DirectoryCursorKey = "\u001f__session_directory"; + + public static async Task ReadAuthorizedAsync( + AccountKeySet account, + IReadOnlyList scopes, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(account); + ArgumentNullException.ThrowIfNull(scopes); + var database = account.Databases.FirstOrDefault(item => + string.Equals(item.RelativePath.Replace('\\', '/'), "session/session.db", StringComparison.OrdinalIgnoreCase)); + if (database is null) + return new DbConversationDirectoryPage([], ["session/session.db"], false, 0); + + var path = DatabasePath(account.AccountRootPath, database.RelativePath); + try + { + var tableRows = await SqlCipherDatabaseReader.QueryRowsAsync(path, database.EncKey, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('SessionTable', 'session') ORDER BY CASE name WHEN 'SessionTable' THEN 0 ELSE 1 END;", + null, cancellationToken).ConfigureAwait(false); + var tableName = tableRows.FirstOrDefault()?.GetValueOrDefault("name") as string; + if (string.IsNullOrWhiteSpace(tableName)) + return new DbConversationDirectoryPage([], ["session/SessionTable"], false, 0); + + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(path, database.EncKey, + $"SELECT username, is_hidden, last_timestamp, sort_timestamp FROM \"{tableName}\" WHERE username IS NOT NULL AND username <> '' ORDER BY sort_timestamp DESC, username ASC;", + null, cancellationToken).ConfigureAwait(false); + var authorized = scopes + .Where(scope => !string.IsNullOrWhiteSpace(scope.ChatId)) + .DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}") + .ToArray(); + var allowed = authorized + .Select(scope => (scope.ChatId, scope.ChatType)) + .ToHashSet(); + var directoryRows = rows + .Select(row => + { + var chatId = row.GetValueOrDefault("username") as string ?? string.Empty; + var chatType = IsGroupChat(chatId) ? ReportingChatType.Group : ReportingChatType.Private; + var lastTimestamp = Convert.ToInt64(row.GetValueOrDefault("last_timestamp") ?? 0L, CultureInfo.InvariantCulture); + var sortTimestamp = Convert.ToInt64(row.GetValueOrDefault("sort_timestamp") ?? 0L, CultureInfo.InvariantCulture); + var timestamp = Math.Max(lastTimestamp, sortTimestamp); + return new + { + ChatId = chatId, + ChatType = chatType, + IsHidden = Convert.ToInt64(row.GetValueOrDefault("is_hidden") ?? 0L, CultureInfo.InvariantCulture) != 0, + LastActivityAt = timestamp > 0 ? DateTimeOffset.FromUnixTimeSeconds(timestamp) : (DateTimeOffset?)null + }; + }) + .Where(row => allowed.Contains((row.ChatId, row.ChatType))) + .ToArray(); + + IReadOnlyDictionary names; + var unavailable = new List(); + try + { + names = await WechatContactDbReader.ReadDisplayNamesAsync(account, + directoryRows.Select(row => row.ChatId).Distinct(StringComparer.Ordinal).ToArray(), cancellationToken).ConfigureAwait(false); + } + catch (WxAgentException) + { + names = new Dictionary(StringComparer.Ordinal); + unavailable.Add("contact/contact.db"); + } + + var conversations = directoryRows.Select(row => + { + var hasName = names.TryGetValue(row.ChatId, out var title) && !string.IsNullOrWhiteSpace(title); + var state = row.IsHidden ? "hidden" : hasName ? "visible" : "unresolved"; + return new DbConversationDirectoryEntry(row.ChatId, row.ChatType, hasName ? title! : string.Empty, + row.LastActivityAt, state); + }).ToArray(); + return new DbConversationDirectoryPage(conversations, unavailable, unavailable.Count == 0, + ComputeDirectoryCursor(conversations)); + } + catch (OperationCanceledException) { throw; } + catch (WxAgentException) + { + return new DbConversationDirectoryPage([], ["session/session.db"], false, 0); + } + } + + private static bool IsGroupChat(string chatId) => chatId.EndsWith("@chatroom", StringComparison.OrdinalIgnoreCase); + + private static long ComputeDirectoryCursor(IReadOnlyList conversations) + { + var canonical = string.Join('\n', conversations + .OrderBy(item => item.ChatId, StringComparer.Ordinal) + .Select(item => string.Join('\u001f', item.ChatId, item.ChatType, item.Title, item.LastActivityAt?.ToUniversalTime().ToString("O") ?? string.Empty, item.DirectoryState))); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); + return BinaryPrimitives.ReadInt64LittleEndian(hash); + } + + private static string DatabasePath(string accountRootPath, string relativePath) + { + var root = Path.GetFullPath(accountRootPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var relative = relativePath.Replace('/', Path.DirectorySeparatorChar); + var path = Path.GetFullPath(Path.Combine(root, relative)); + if (Path.IsPathRooted(relative) || !path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The database path must remain inside the selected account root."); + return path; + } +}