Files
wx-win-agent/node-agent/WxAgent.Windows/WechatSessionDbReader.cs
T

140 lines
7.0 KiB
C#

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<DbConversationDirectoryEntry> Conversations,
IReadOnlyList<string> UnavailableDatabases,
bool IsComplete,
long DirectoryCursor);
/// <summary>
/// Reads the real WeChat session directory before applying reporting scopes.
/// Scope configuration is only a filter; it never creates a conversation row.
/// </summary>
public static class WechatSessionDbReader
{
public const string DirectoryCursorKey = "\u001f__session_directory";
public static async Task<DbConversationDirectoryPage> ReadAuthorizedAsync(
AccountKeySet account,
IReadOnlyList<RemoteReportingScope> 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<string, string> names;
var unavailable = new List<string>();
try
{
names = await WechatContactDbReader.ReadDisplayNamesAsync(account,
directoryRows.Select(row => row.ChatId).Distinct(StringComparer.Ordinal).ToArray(), cancellationToken).ConfigureAwait(false);
}
catch (WxAgentException)
{
names = new Dictionary<string, string>(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<DbConversationDirectoryEntry> 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;
}
}