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 allChats = authorized.Any(scope => scope.ChatId == "*"); var allowed = authorized .Where(scope => scope.ChatId != "*") .Select(scope => (scope.ChatId, scope.ChatType)) .ToHashSet(); var allowedTypes = authorized .Where(scope => scope.ChatId == "*") .Select(scope => 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 => allChats ? allowedTypes.Contains(row.ChatType) : 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; } }