160 lines
11 KiB
C#
160 lines
11 KiB
C#
using WxAgent.Core;
|
|
using WxAgent.Service;
|
|
using WxAgent.Windows;
|
|
|
|
namespace WxAgent.Host;
|
|
|
|
internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBackend, IAgentEventSource
|
|
{
|
|
public IReadOnlyList<AgentCapability> Capabilities { get; } =
|
|
[
|
|
new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"),
|
|
new("session-open", true, false, false, true, true, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"),
|
|
new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
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.63"),
|
|
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.63"),
|
|
new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("listener-events", true, options.EnableListenerEvents, options.EnableListenerEvents, true, false, "read", false, 30,
|
|
options.EnableListenerEvents ? null : "Listener events require explicit opt-in and recovery validation.", ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
|
new("send-text", true, false, false, true, true, "write", false, 30,
|
|
"Active UI/database account binding has not been verified for this service session.", []),
|
|
new("group-at-all", true, false, false, true, true, "write", true, 30,
|
|
"Service account binding and group-owner validation required.", []),
|
|
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 checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", "listener-checkpoint.json");
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken,
|
|
session: options.ListenerSession))
|
|
{
|
|
yield return new AgentEvent(item.EventId, "ui-current", item.Session,
|
|
item.Kind.ToString(), item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt);
|
|
}
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken);
|
|
// Database fingerprints are deliberately not merged with the current UI identity.
|
|
return Task.FromResult<IReadOnlyList<AccountInfo>>(roots.Select(root => new AccountInfo(root.Fingerprint, null, null, null,
|
|
root.Fingerprint, false)).ToArray());
|
|
}
|
|
|
|
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 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 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 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 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 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 async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken)
|
|
{
|
|
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,
|
|
report.WindowFound,
|
|
errors = report.Errors.Select(e => e.ToString()),
|
|
wechatVersions = report.Processes.Select(p => p.Version).Where(v => v is not null).Distinct(),
|
|
activeAccountBound = false,
|
|
defaultReadOnly = true
|
|
});
|
|
}
|
|
}
|