fix: source synced conversations from session database

This commit is contained in:
2026-09-22 10:11:55 +08:00
parent 72e040546a
commit 4f4e5835f9
4 changed files with 189 additions and 10 deletions
@@ -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"),
@@ -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;
}
}