475 lines
31 KiB
C#
475 lines
31 KiB
C#
using WxAgent.Core;
|
|
using WxAgent.Service;
|
|
using WxAgent.Windows;
|
|
|
|
namespace WxAgent.Host;
|
|
|
|
public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOptions options) : IAgentBackend, IAgentEventSource
|
|
{
|
|
private const bool ListenerEventsEnabled = true;
|
|
private readonly bool validationOperationsEnabled = options.EnableValidationOperations;
|
|
private readonly SemaphoreSlim bindingGate = new(1, 1);
|
|
|
|
public IReadOnlyList<AgentCapability> Capabilities =>
|
|
[
|
|
new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("accounts-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("account-binding", true, false, true, true, true, "manage", false, 30, "Auto-binds a unique database/UI identity match; manual binding remains available as fallback.", ["docs/WebUI-MCP-开发计划.md"]),
|
|
new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("sessions-scroll", true, false, validationOperationsEnabled, true, false, "manage", false, 30,
|
|
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
|
|
new("session-open", true, false, validationOperationsEnabled, true, true, "manage", false, 30,
|
|
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("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 : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("send-text", true, true, validationOperationsEnabled, true, true, "write", true, 30,
|
|
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("broadcast-text", true, true, validationOperationsEnabled, true, true, "write", true, 300,
|
|
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
|
new("group-at-all", true, false, false, true, true, "write", true, 30,
|
|
"No group-at-all operation is registered; validate group targets through confirmed send-text tasks.", []),
|
|
new("next-unread", false, false, false, true, false, "read", false, 30,
|
|
"Deferred by docs/PENDING.md.", [])
|
|
];
|
|
|
|
public async IAsyncEnumerable<AgentEvent> ListenAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
var active = bindings.ReadAll();
|
|
if (active.Count != 1)
|
|
{
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
|
yield break;
|
|
}
|
|
var binding = active[0];
|
|
using var scope = WechatChatClient.UseWindowTarget(binding.ProcessId, binding.WindowHandle);
|
|
var current = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
|
if (!BindingMatches(binding, current))
|
|
throw new ServiceException("AccountBindingStale", 409, "The listener binding is stale; bind the account again.");
|
|
var sessions = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken))
|
|
.Where(session => string.Equals(session.Name, WechatLocators.FileTransferAssistant, StringComparison.Ordinal)
|
|
&& !string.IsNullOrWhiteSpace(session.AutomationId))
|
|
.ToArray();
|
|
if (sessions.Length != 1)
|
|
throw new ServiceException("ChatIdentityUnconfirmed", 409, "The listener chat identity could not be uniquely confirmed.");
|
|
var chatId = sessions[0].AutomationId;
|
|
var accountTag = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(binding.AccountId)))[..16].ToLowerInvariant();
|
|
var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", $"listener-{accountTag}.json");
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken,
|
|
session: WechatLocators.FileTransferAssistant))
|
|
{
|
|
var observedIdentity = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
|
if (!BindingMatches(binding, observedIdentity))
|
|
throw new ServiceException("AccountBindingStale", 409, "The listener binding changed; reporting is paused until the account is rebound.");
|
|
var eventType = item.Kind == MessageEventKind.MessageReceived ? "message" : "listener.reconnected";
|
|
yield return new AgentEvent(item.EventId, binding.AccountId, item.Session,
|
|
eventType, item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt,
|
|
chatId, ReportingChatType.Private, item.Message?.Text);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool BindingMatches(AccountBinding binding, WechatAccountSnapshot current) =>
|
|
!string.IsNullOrWhiteSpace(binding.WechatId)
|
|
? string.Equals(binding.WechatId, current.WechatId, StringComparison.OrdinalIgnoreCase)
|
|
: string.Equals(binding.Nickname, current.DisplayName, StringComparison.Ordinal);
|
|
|
|
public async Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var targets = await ReadUiTargetsAsync(cancellationToken, true);
|
|
var accounts = await ReadDatabaseAccountsAsync(cancellationToken);
|
|
await AutoBindMatchesAsync(accounts, targets, cancellationToken);
|
|
var current = bindings.ReadAll();
|
|
var live = current.Where(binding => targets.Any(target =>
|
|
target.ProcessId == binding.ProcessId && target.WindowHandle == binding.WindowHandle &&
|
|
(string.IsNullOrWhiteSpace(target.WechatId) && string.IsNullOrWhiteSpace(target.Nickname) ||
|
|
BindingMatches(binding, new WechatAccountSnapshot(target.Nickname ?? string.Empty, target.WechatId)))))
|
|
.Select(binding => binding.AccountId)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
return accounts.Select(account =>
|
|
{
|
|
var binding = bindings.Get(account.AccountId);
|
|
var isLive = binding is not null && live.Contains(binding.AccountId);
|
|
return new AccountInfo(account.AccountId, account.Identity?.Nickname, account.Identity?.WechatId, null,
|
|
account.AccountId, isLive, binding, binding is null ? "Unbound" : isLive ? "Bound" : "Stale");
|
|
}).ToArray();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var targets = await ReadUiTargetsAsync(cancellationToken, true);
|
|
var accounts = await ReadDatabaseAccountsAsync(cancellationToken);
|
|
await AutoBindMatchesAsync(accounts, targets, cancellationToken);
|
|
var bound = bindings.ReadAll().Select(binding => (binding.ProcessId, binding.WindowHandle))
|
|
.ToHashSet(new BindingTargetComparer());
|
|
return targets.Select(target => target with
|
|
{
|
|
IsBound = bound.Contains((target.ProcessId, target.WindowHandle))
|
|
}).ToArray();
|
|
}
|
|
|
|
private async Task<IReadOnlyList<UiTargetInfo>> ReadUiTargetsAsync(CancellationToken cancellationToken, bool readIdentity)
|
|
{
|
|
var bound = bindings.ReadAll().Select(binding => (binding.ProcessId, binding.WindowHandle)).ToHashSet(new BindingTargetComparer());
|
|
var targets = new List<UiTargetInfo>();
|
|
foreach (var window in WechatChatClient.InspectMainWindows().Where(window => window.Visible && !window.Minimized))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
string? wechatId = null;
|
|
string? nickname = null;
|
|
if (readIdentity)
|
|
{
|
|
try
|
|
{
|
|
using var scope = WechatChatClient.UseWindowTarget(window.ProcessId, window.Handle);
|
|
var identity = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
|
wechatId = identity.WechatId;
|
|
nickname = identity.DisplayName;
|
|
}
|
|
catch (WxAgentException) { }
|
|
}
|
|
targets.Add(new UiTargetInfo(TargetId(window.ProcessId, window.Handle), window.ProcessId, window.Handle,
|
|
window.Title, wechatId, nickname, bound.Contains((window.ProcessId, window.Handle))));
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
public async Task<AccountBinding> BindAccountAsync(string accountId, string targetId, CancellationToken cancellationToken)
|
|
{
|
|
if (!TryParseTargetId(targetId, out var processId, out var windowHandle))
|
|
throw new ServiceException("InvalidTarget", 400, "targetId is not a current WeChat window target.");
|
|
var target = (await ReadUiTargetsAsync(cancellationToken, true)).SingleOrDefault(x => x.TargetId == targetId);
|
|
if (target is null) throw new ServiceException("TargetUnavailable", 409, "The selected WeChat window is not available.");
|
|
|
|
var account = await WechatContactDbReader.LoadAccountAsync(accountId, cancellationToken: cancellationToken);
|
|
WechatAccountSnapshot ui;
|
|
if (!string.IsNullOrWhiteSpace(target.WechatId) || !string.IsNullOrWhiteSpace(target.Nickname))
|
|
ui = new WechatAccountSnapshot(target.Nickname ?? string.Empty, target.WechatId);
|
|
else
|
|
{
|
|
using var scope = WechatChatClient.UseWindowTarget(processId, windowHandle);
|
|
ui = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
|
}
|
|
var databaseIdentity = await TryReadDatabaseIdentityAsync(account, cancellationToken)
|
|
?? await TryReadDatabaseIdentityAsync(account, ui, cancellationToken)
|
|
?? throw new ServiceException("AccountIdentityUnavailable", 409, "The selected database account identity could not be read safely.");
|
|
var match = AccountBindingMatcher.Match(new UiAccountIdentity(ui.WechatId, ui.DisplayName), [databaseIdentity]);
|
|
if (!match.IsMatched || !string.Equals(match.AccountId, account.AccountRootFingerprint, StringComparison.OrdinalIgnoreCase))
|
|
throw new ServiceException("AccountBindingMismatch", 409, "The selected WeChat window does not match the selected database account.");
|
|
|
|
await bindingGate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
var current = bindings.ReadAll();
|
|
var accountMatches = current.Where(x => string.Equals(x.AccountId, account.AccountRootFingerprint, StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
if (accountMatches.Length > 1)
|
|
throw new ServiceException("BindingAmbiguous", 409, "The persisted account binding is ambiguous; remove account-bindings.json and bind again.");
|
|
var accountBinding = accountMatches.SingleOrDefault();
|
|
if (accountBinding is not null && (accountBinding.ProcessId != processId || accountBinding.WindowHandle != windowHandle))
|
|
throw new ServiceException("AccountAlreadyBound", 409, "The account is already bound to another WeChat window; unbind it first.");
|
|
var targetMatches = current.Where(x => x.ProcessId == processId && x.WindowHandle == windowHandle).ToArray();
|
|
if (targetMatches.Length > 1)
|
|
throw new ServiceException("BindingAmbiguous", 409, "The persisted window binding is ambiguous; remove account-bindings.json and bind again.");
|
|
var targetBinding = targetMatches.SingleOrDefault();
|
|
if (targetBinding is not null && !string.Equals(targetBinding.AccountId, account.AccountRootFingerprint, StringComparison.OrdinalIgnoreCase))
|
|
throw new ServiceException("TargetAlreadyBound", 409, "The selected WeChat window is already bound to another account.");
|
|
|
|
var binding = new AccountBinding(account.AccountRootFingerprint, processId, windowHandle,
|
|
ui.WechatId, ui.DisplayName ?? string.Empty, DateTimeOffset.UtcNow);
|
|
bindings.Replace(current.Where(x => !string.Equals(x.AccountId, binding.AccountId, StringComparison.OrdinalIgnoreCase)).Append(binding));
|
|
return binding;
|
|
}
|
|
finally { bindingGate.Release(); }
|
|
}
|
|
|
|
public async Task UnbindAccountAsync(string accountId, CancellationToken cancellationToken)
|
|
{
|
|
await bindingGate.WaitAsync(cancellationToken);
|
|
try { bindings.Remove(accountId); }
|
|
finally { bindingGate.Release(); }
|
|
}
|
|
|
|
private static string TargetId(int processId, long windowHandle) =>
|
|
$"{processId.ToString(System.Globalization.CultureInfo.InvariantCulture)}:{windowHandle.ToString(System.Globalization.CultureInfo.InvariantCulture)}";
|
|
|
|
private static bool TryParseTargetId(string targetId, out int processId, out long windowHandle)
|
|
{
|
|
processId = 0;
|
|
windowHandle = 0;
|
|
var parts = targetId.Split(':', 2);
|
|
return parts.Length == 2 && int.TryParse(parts[0], System.Globalization.NumberStyles.None,
|
|
System.Globalization.CultureInfo.InvariantCulture, out processId) &&
|
|
long.TryParse(parts[1], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out windowHandle) &&
|
|
processId > 0 && windowHandle > 0;
|
|
}
|
|
|
|
private async Task<IReadOnlyList<DatabaseAccount>> ReadDatabaseAccountsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken);
|
|
var accounts = new List<DatabaseAccount>(roots.Count);
|
|
foreach (var root in roots)
|
|
{
|
|
DatabaseAccountIdentity? identity = null;
|
|
try
|
|
{
|
|
var account = await WechatContactDbReader.LoadAccountAsync(root.Fingerprint, cancellationToken: cancellationToken);
|
|
identity = await TryReadDatabaseIdentityAsync(account, cancellationToken);
|
|
}
|
|
catch (WxAgentException) { }
|
|
accounts.Add(new(root.Fingerprint, identity));
|
|
}
|
|
return accounts;
|
|
}
|
|
|
|
private async Task AutoBindMatchesAsync(IReadOnlyList<DatabaseAccount> accounts,
|
|
IReadOnlyList<UiTargetInfo> targets, CancellationToken cancellationToken)
|
|
{
|
|
if (accounts.All(account => account.Identity is null) || targets.Count == 0) return;
|
|
await bindingGate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
var current = bindings.ReadAll().ToList();
|
|
var changed = false;
|
|
foreach (var target in targets.Where(target => !string.IsNullOrWhiteSpace(target.WechatId) || !string.IsNullOrWhiteSpace(target.Nickname)))
|
|
{
|
|
var existing = current.FirstOrDefault(binding => binding.ProcessId == target.ProcessId && binding.WindowHandle == target.WindowHandle);
|
|
var available = accounts.Where(account => account.Identity is not null &&
|
|
(existing is not null && string.Equals(existing.AccountId, account.AccountId, StringComparison.OrdinalIgnoreCase) ||
|
|
!current.Any(binding => string.Equals(binding.AccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))))
|
|
.Select(account => account.Identity!)
|
|
.ToArray();
|
|
var match = AccountBindingMatcher.Match(new UiAccountIdentity(target.WechatId, target.Nickname), available);
|
|
if (!match.IsMatched) continue;
|
|
var identity = available.Single(account => string.Equals(account.AccountId, match.AccountId, StringComparison.OrdinalIgnoreCase));
|
|
var binding = new AccountBinding(identity.AccountId, target.ProcessId, target.WindowHandle,
|
|
target.WechatId, target.Nickname ?? string.Empty, existing?.BoundAt ?? DateTimeOffset.UtcNow);
|
|
if (existing is null)
|
|
{
|
|
current.Add(binding);
|
|
changed = true;
|
|
}
|
|
else if (existing.AccountId.Equals(binding.AccountId, StringComparison.OrdinalIgnoreCase) &&
|
|
(!string.Equals(existing.WechatId, binding.WechatId, StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(existing.Nickname, binding.Nickname, StringComparison.Ordinal)))
|
|
{
|
|
current[current.IndexOf(existing)] = binding;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) bindings.Replace(current);
|
|
}
|
|
finally { bindingGate.Release(); }
|
|
}
|
|
|
|
private static async Task<DatabaseAccountIdentity?> TryReadDatabaseIdentityAsync(AccountKeySet account,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try { return await WechatContactDbReader.ReadAccountIdentityAsync(account, cancellationToken); }
|
|
catch (WxAgentException) { return null; }
|
|
}
|
|
|
|
private static async Task<DatabaseAccountIdentity?> TryReadDatabaseIdentityAsync(AccountKeySet account,
|
|
WechatAccountSnapshot ui, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(ui.WechatId))
|
|
{
|
|
var page = await WechatContactDbReader.ReadPageAsync(account, 10000, 0, ui.WechatId, false, cancellationToken);
|
|
var exact = page.Contacts.Where(x => string.Equals(x.Username, ui.WechatId, StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
if (exact.Length == 1)
|
|
return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName);
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(ui.DisplayName))
|
|
{
|
|
var page = await WechatContactDbReader.ReadPageAsync(account, 10000, 0, ui.DisplayName, false, cancellationToken);
|
|
var exact = page.Contacts.Where(x => string.Equals(x.DisplayName, ui.DisplayName, StringComparison.Ordinal) ||
|
|
string.Equals(x.Remark, ui.DisplayName, StringComparison.Ordinal)).ToArray();
|
|
if (exact.Length == 1)
|
|
return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName);
|
|
}
|
|
return null;
|
|
}
|
|
catch (WxAgentException) { return null; }
|
|
}
|
|
|
|
private sealed record DatabaseAccount(string AccountId, DatabaseAccountIdentity? Identity);
|
|
|
|
private sealed class BindingTargetComparer : IEqualityComparer<(int ProcessId, long WindowHandle)>
|
|
{
|
|
public bool Equals((int ProcessId, long WindowHandle) x, (int ProcessId, long WindowHandle) y) => x == y;
|
|
public int GetHashCode((int ProcessId, long WindowHandle) value) => HashCode.Combine(value.ProcessId, value.WindowHandle);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken);
|
|
return sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray();
|
|
}
|
|
|
|
public Task<IReadOnlyList<SessionInfo>> SessionsAsync(string? accountId, CancellationToken cancellationToken) =>
|
|
ForAccountAsync(accountId, cancellationToken, () => SessionsAsync(cancellationToken));
|
|
|
|
public async Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken)
|
|
{
|
|
var results = await WechatChatClient.SearchSessionsAsync(query, exactOnly, cancellationToken);
|
|
return results.Select(s => new SessionSearchInfo(s.Name, s.AutomationId, s.IsExactMatch)).ToArray();
|
|
}
|
|
|
|
public Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) =>
|
|
ForAccountAsync(accountId, cancellationToken, () => SearchSessionsAsync(query, exactOnly, cancellationToken));
|
|
|
|
public async Task<SessionInfo?> CurrentSessionAsync(CancellationToken cancellationToken)
|
|
{
|
|
var current = await WechatChatClient.GetCurrentSessionAsync(cancellationToken);
|
|
return current is null ? null : new SessionInfo(current.Name, current.AutomationId, true);
|
|
}
|
|
|
|
public Task<SessionInfo?> CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) =>
|
|
ForAccountAsync(accountId, cancellationToken, () => CurrentSessionAsync(cancellationToken));
|
|
|
|
public async Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken cancellationToken)
|
|
{
|
|
var matches = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken)).Where(s => s.AutomationId == automationId).ToArray();
|
|
if (matches.Length != 1) throw new ServiceException(matches.Length == 0 ? "NotFound" : "AmbiguousTarget", 409, "AutomationId is not unique.");
|
|
await WechatChatClient.OpenSessionAsync(matches[0].Name, cancellationToken, automationId);
|
|
return new SessionInfo(matches[0].Name, matches[0].AutomationId, true);
|
|
}
|
|
|
|
public Task<SessionInfo> OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) =>
|
|
ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken));
|
|
|
|
public async Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken)
|
|
{
|
|
var result = await WechatChatClient.ScrollSessionsAsync(direction == "up" ? WechatScrollDirection.Up : WechatScrollDirection.Down, pages, cancellationToken);
|
|
return new SessionViewportInfo(result.Scrolls, result.ViewportChanged,
|
|
result.Sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray());
|
|
}
|
|
|
|
public Task<SessionViewportInfo> ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) =>
|
|
ForAccountAsync(accountId, cancellationToken, () => ScrollSessionsAsync(direction, pages, cancellationToken));
|
|
|
|
public async Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(session))
|
|
{
|
|
var matches = await WechatChatClient.SearchSessionsAsync(session, exactOnly: true, cancellationToken: cancellationToken);
|
|
if (matches.Count != 1) throw new ServiceException(matches.Count == 0 ? "NotFound" : "AmbiguousTarget", 409, "Session name is not unique or not found.");
|
|
await WechatChatClient.OpenSessionAsync(matches[0].Name, cancellationToken, session);
|
|
}
|
|
var messages = await WechatChatClient.ReadVisibleAsync(cancellationToken);
|
|
return messages.Select(m => new MessageInfo(m.Fingerprint, m.Type.ToString(), m.Quote?.Sender,
|
|
m.Text.Length > 160 ? m.Text[..160] : m.Text, includeContent ? m.Text : null)).ToArray();
|
|
}
|
|
|
|
public Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) =>
|
|
ForAccountAsync(accountId, cancellationToken, () => MessagesAsync(session, includeContent, cancellationToken));
|
|
|
|
public async Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken)
|
|
{
|
|
await ForAccountAsync(accountId, cancellationToken, async () =>
|
|
{
|
|
var matches = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken))
|
|
.Where(session => string.Equals(session.AutomationId, targetId, StringComparison.Ordinal)).ToArray();
|
|
if (matches.Length != 1)
|
|
throw new ServiceException(matches.Length == 0 ? "NotFound" : "AmbiguousTarget", 409, "The selected session is not unique or no longer visible.");
|
|
await WechatChatClient.SendTextAsync(text, cancellationToken, matches[0].Name);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private async Task<T> ForAccountAsync<T>(string? accountId, CancellationToken cancellationToken, Func<Task<T>> action)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(accountId))
|
|
throw new ServiceException("AccountIdRequired", 400, "accountId is required for every WeChat UI operation.");
|
|
var matches = bindings.ReadAll().Where(x => string.Equals(x.AccountId, accountId, StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
if (matches.Length > 1)
|
|
throw new ServiceException("BindingAmbiguous", 409, "The persisted account binding is ambiguous; bind state must be repaired before use.");
|
|
var binding = matches.SingleOrDefault();
|
|
if (binding is null)
|
|
throw new ServiceException("AccountNotBound", 409, "The account is not explicitly bound to a WeChat window.");
|
|
var target = (await ReadUiTargetsAsync(cancellationToken, false)).SingleOrDefault(x =>
|
|
x.ProcessId == binding.ProcessId && x.WindowHandle == binding.WindowHandle);
|
|
if (target is null)
|
|
throw new ServiceException("AccountWindowUnavailable", 409, "The bound WeChat window is no longer available.");
|
|
using var scope = WechatChatClient.UseWindowTarget(binding.ProcessId, binding.WindowHandle);
|
|
var current = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
|
var identityMatches = !string.IsNullOrWhiteSpace(binding.WechatId)
|
|
? string.Equals(binding.WechatId, current.WechatId, StringComparison.OrdinalIgnoreCase)
|
|
: string.Equals(binding.Nickname, current.DisplayName, StringComparison.Ordinal);
|
|
if (!identityMatches)
|
|
throw new ServiceException("AccountBindingStale", 409, "The bound WeChat window identity changed; bind the account again.");
|
|
return await action();
|
|
}
|
|
|
|
public async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(accountId))
|
|
throw new ServiceException("AccountIdRequired", 400, "accountId is required for database contact operations.");
|
|
var page = await WechatChatClient.GetContactsPageAsync(limit, offset, contains, groupsOnly, accountId, cancellationToken: cancellationToken);
|
|
var items = page.Contacts.Select(c => new ContactInfo(c.Username, c.DisplayName, c.Remark, null)).ToArray();
|
|
return new Page<ContactInfo>(items, limit, offset, page.HasMore, page.NextOffset);
|
|
}
|
|
|
|
public async Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken)
|
|
{
|
|
var page = await WechatChatClient.GetGroupMembersPageAsync(group, limit, offset, accountId, cancellationToken: cancellationToken);
|
|
var items = page.Members.Select(m => new GroupMemberInfo(m.MemberId, m.Username, m.DisplayName, m.IsOwner)).ToArray();
|
|
return new Page<GroupMemberInfo>(items, limit, offset, page.HasMore, page.NextOffset);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken)
|
|
{
|
|
var account = await DatabaseKeyStore.LoadAsync(null, cancellationToken);
|
|
var selected = account.SingleOrDefault(a => string.Equals(a.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase))
|
|
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No verified database account matches the selected fingerprint.");
|
|
var messages = await WechatMessageDbReader.ReadAsync(selected.AccountRootPath, selected.Databases, chatId, limit, cancellationToken, localId);
|
|
return messages.Select(m => new DatabaseMessageInfo(m.LocalId, m.ServerId, m.ChatId, m.SenderWxId, m.SenderName, m.Type, m.Content, m.Timestamp, m.IsSelf)).ToArray();
|
|
}
|
|
|
|
public async Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken)
|
|
{
|
|
var account = await DatabaseKeyStore.LoadAsync(null, cancellationToken);
|
|
var selected = account.SingleOrDefault(a => string.Equals(a.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase))
|
|
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No verified database account matches the selected fingerprint.");
|
|
var merged = await WechatMessageDbReader.ReadMergedAsync(selected.AccountRootPath, selected.Databases, chatId, localId, cancellationToken);
|
|
var parent = merged.Parent;
|
|
var messages = merged.Record.Messages.Select(m => new MergedMessagePart(m.SenderName, m.Text, m.Timestamp, m.Path, m.DataType)).ToArray();
|
|
return new MergedMessageInfo(merged.DatabaseRelativePath,
|
|
new DatabaseMessageInfo(parent.LocalId, parent.ServerId, parent.ChatId, parent.SenderWxId, parent.SenderName, parent.Type, parent.Content, parent.Timestamp, parent.IsSelf),
|
|
merged.Record.Title, merged.Record.Description, messages);
|
|
}
|
|
|
|
public async Task<object> DiagnoseAsync(CancellationToken cancellationToken)
|
|
{
|
|
var result = await StatusAsync(cancellationToken);
|
|
return new { diagnostic = true, redacted = true, result };
|
|
}
|
|
|
|
public Task<object> StatusAsync(CancellationToken cancellationToken)
|
|
{
|
|
var report = WechatDoctor.Run(cancellationToken);
|
|
return Task.FromResult<object>(new
|
|
{
|
|
serviceOnline = true,
|
|
wechatAvailable = report.Errors.Count == 0,
|
|
sessionAvailable = report.UserInteractive && report.InputDesktopAvailable,
|
|
sessionLocked = report.Errors.Contains(WxAgentErrorCode.SessionLocked),
|
|
report.WindowFound,
|
|
errors = report.Errors.Select(e => e.ToString()),
|
|
wechatVersions = report.Processes.Select(p => p.Version).Where(v => v is not null).Distinct(),
|
|
activeAccountBound = bindings.ReadAll().Count != 0,
|
|
defaultReadOnly = true
|
|
});
|
|
}
|
|
}
|