fix: source synced conversations from session database
This commit is contained in:
@@ -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<string, long>(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)
|
||||
|
||||
@@ -59,6 +59,40 @@ public static class WechatContactDbReader
|
||||
return WechatContactQuery.Page(contacts, limit, offset);
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyDictionary<string, string>> ReadDisplayNamesAsync(
|
||||
AccountKeySet account, IReadOnlyList<string> 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<string, string>(StringComparer.Ordinal);
|
||||
|
||||
var database = RequireContactDatabase(account);
|
||||
var result = new Dictionary<string, string>(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<string, object?>($"$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<WechatGroupMemberPage> ReadGroupMembersPageAsync(AccountKeySet account, string group,
|
||||
int limit = 500, int offset = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -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<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 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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user