140 lines
7.0 KiB
C#
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;
|
|
}
|
|
}
|