feat: add explicit multi-account window binding

This commit is contained in:
2026-09-08 00:43:51 +08:00
parent 4613289e19
commit e169cba8be
16 changed files with 463 additions and 61 deletions
+8 -1
View File
@@ -31,9 +31,16 @@ MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session
原子替换凭据文件并保留相同 `PrincipalId` 可保留幂等记录;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。
## 多账号显式绑定
1. 在未锁定的交互式 Windows 会话中读取 `GET /api/v1/accounts` 和 `GET /api/v1/ui-targets`。
2. 用户选择一个数据库 `accountId` 和一个当前窗口 `targetId`(PID+HWND),调用 `POST /api/v1/accounts/bind`;服务会重新读取 UI 微信号/昵称,并与已验证 `contact.db` 身份匹配,重复、过期或不匹配均拒绝。
3. 绑定状态保存在服务数据目录的 `account-bindings.json`。微信进程/窗口消失、身份变化或重启后窗口句柄失效时,必须重新绑定;调用 `POST /api/v1/accounts/unbind` 可主动解除。
4. 会话、可见消息、联系人等操作必须显式携带已绑定 `accountId`;未绑定返回 `AccountNotBound`/`AccountWindowUnavailable`,不会猜测窗口。
## 只读边界
状态、账号、会话、摘要消息、联系人和任务查询通过 REST 或同一 MCP 工具访问。正文需 `content` 权限。当前写入、@所有人、管理和朋友圈能力保持 disabled;不会把入队、UI 点击或数据库指纹描述成已发送。
状态、账号、窗口目标、会话、摘要消息、联系人和任务查询通过 REST 或同一 MCP 工具访问。正文需 `content` 权限。当前写入、@所有人、管理和朋友圈能力保持 disabled;不会把入队、UI 点击或数据库指纹描述成已发送。
## 回滚
+5 -4
View File
@@ -6,13 +6,14 @@
|---|---|---|---|---|
| 服务/微信诊断 | `GET /api/v1/status`、`/api/v1/diagnostics` | `agent_status`、`agent_diagnose` | Ready/环境依赖 | 脱敏;服务在线不等于微信可用,不执行恢复或 UI 写操作 |
| 能力清单 | `GET /api/v1/capabilities` | `agent_capabilities` | Ready | 各项 implemented/validated/enabled 分离 |
| 数据库账号发现 | `GET /api/v1/accounts` | `accounts_list` | 只读 | 仅返回指纹和脱敏 UI 信息,不返回密钥 |
| 可见会话 | `GET /api/v1/sessions`、`/search`、`/current`;POST `/open`、`/scroll` | `sessions_list`、`sessions_search`、`session_current` | 只读/导航 | 精确匹配拒绝猜测;open/scroll 需 manage 且当前仍待导航验收 |
| 可见消息 | `GET /api/v1/messages` | `messages_read` | 只读 | 默认摘要;`includeContent` 需要 content 权限 |
| 数据库账号发现 | `GET /api/v1/accounts` | `accounts_list` | 只读 | 仅返回指纹和绑定状态,不返回密钥 |
| 窗口枚举/显式绑定 | `GET /api/v1/ui-targets`、`POST /api/v1/accounts/bind`、`/unbind` | `ui_targets`、`account_bind`、`account_unbind` | Ready/需真机 | 用户选择 PID+HWND;绑定前读取 UI 身份并与已验证 contact.db 身份匹配,重复/不匹配拒绝 |
| 可见会话 | `GET /api/v1/sessions?accountId=...`、`/search`、`/current`;POST `/open`、`/scroll` | `sessions_list`、`sessions_search`、`session_current` | 只读/导航 | 所有 UI 调用必须携带已绑定 accountId;精确匹配拒绝猜测;open/scroll 需 manage 且当前仍待导航验收 |
| 可见消息 | `GET /api/v1/messages?accountId=...` | `messages_read` | 只读 | 必须使用已绑定 accountId;默认摘要;`includeContent` 需要 content 权限 |
| 数据库消息/合并记录 | `GET /api/v1/db/messages`、`/api/v1/db/merged` | `db_messages`、`db_merged` | Implemented but disabled | 仅接受显式已验证账号 fingerprint;只读 SQLCipher,未提供通用 SQL |
| 任务查询/取消 | `/api/v1/operations/{id}` | `operation_get`/`operation_cancel` | Ready | 只允许任务所有者;取消不撤销已发生副作用 |
| 事件流 | `GET /api/v1/events` | 暂未注册 | Explicit opt-in | SSE 有界缓存、Last-Event-ID gap、Windows `ListenEventsAsync` 已接入;默认关闭,需受控真机验收后开启 |
| 文本/文件/卡片发送 | — | — | Disabled | 账号绑定、目标唯一性、真机后置验证未完成;绝不模拟成功 |
| 文本/文件/卡片发送 | — | — | Disabled | 绑定验证已具备,但写能力仍需目标唯一性、写后确认和真机验收;绝不模拟成功 |
| 联系人/群管理、朋友圈 | — | — | Deferred/Disabled | 遵循 `docs/PENDING.md`,需单项授权和真机证据 |
| 任意 SQL/UI 菜单/shell | — | — | Unsupported | 不提供 |
+15 -8
View File
@@ -13,14 +13,15 @@
| 能力 | 入口 | 结果 |
|---|---|---|
| 服务/微信诊断 | `GET /api/v1/status` | HTTP 200;`serviceOnline=true`、`wechatAvailable=true`、`sessionAvailable=true`、`windowFound=true`,错误为空 |
| 会话列表 | `GET /api/v1/sessions` | HTTP 200;返回可见会话,含 automationId |
| 会话当前/精确搜索 | `/api/v1/sessions/current`、`/search?exactOnly=true` | HTTP 200;当前会话和“文件传输助手”精确匹配均返回稳定 automationId |
| 账号发现 | `GET /api/v1/accounts` | HTTP 200;返回数据库指纹;UI 绑定保持 false |
| 联系人只读 | `GET /api/v1/contacts` | HTTP 200;返回稳定联系人 ID;未输出密钥 |
| 会话列表 | `GET /api/v1/sessions?accountId=...` | HTTP 200;返回可见会话,含 automationId;未携带 accountId 拒绝执行 |
| 会话当前/精确搜索 | `/api/v1/sessions/current?accountId=...`、`/search?accountId=...&exactOnly=true` | 需要显式绑定账号;当前锁屏环境不作为真机成功证据 |
| 账号发现/窗口目标 | `GET /api/v1/accounts`、`GET /api/v1/ui-targets` | HTTP 200;返回数据库指纹、绑定状态和当前窗口目标 |
| 显式账号绑定 | `POST /api/v1/accounts/bind` | 已实现身份匹配、PID+HWND 持久化和冲突拒绝;锁屏环境未执行成功绑定 |
| 联系人只读 | `GET /api/v1/contacts?accountId=...` | 需要显式账号;返回稳定联系人 ID;未输出密钥 |
| 群成员只读 | `GET /api/v1/groups/{accountId}/{group}/members` | HTTP 200;显式账号范围与测试群 `消息测试专用群组`,返回稳定成员 ID |
| SSE 单帧 | `GET /api/v1/events` | HTTP 200;真实 `id/event/data` 帧,正文不进入事件;默认仍需显式 opt-in |
| 可见消息摘要 | `GET /api/v1/messages` | HTTP 200;返回 fingerprint/type/summary,正文为 null |
| 可见消息摘要 | `GET /api/v1/messages?accountId=...` | 需要显式绑定账号;返回 fingerprint/type/summary,正文为 null |
| MCP | `/mcp` initialize、`tools/list` | HTTP 200;同一 Bearer Token;工具 schema 可加载 |
| Web 静态入口 | `/` | HTTP 200;静态资源随发布包部署 |
@@ -36,14 +37,20 @@
## 自动化证据
- `dotnet test tests/WxAgent.Core.Tests -c Release`:150 passed。
- `dotnet test tests/WxAgent.Service.Tests -c Release`:11 passed。
- `dotnet test tests/WxAgent.Core.Tests -c Release`:153 passed。
- `dotnet test tests/WxAgent.Service.Tests -c Release`:12 passed。
- `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true`:成功,0 warning/0 error。
- self-contained `win-x64` publish:成功,包含 `wwwroot` 与 MCP 依赖。
## 本轮多账号绑定验证
- Windows API 实例在锁屏/非交互 SSH 会话下返回 1 个脱敏 PID+HWND 目标和 1 个 `Unbound` 数据库账号;身份字段保持空值,未猜测绑定。
- 缺少 `accountId` 的 sessions/contacts/messages 请求分别返回 HTTP 400 `AccountIdRequired`。
- `doctor`/`inspect-ui`/`smoke` 在当前会话返回 `SessionLocked`/`WindowNotFound`;未把锁屏结果当作绑定成功证据。
## 未完成/不宣称
- 未执行发送、联系人/群管理、朋友圈、语音等写操作;`send-text`、`group-at-all` 和 deferred 项保持禁用。
- 未完成 60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,默认配置 `EnableListenerEvents=false`。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。
- 未完成窗口绑定成功和 60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,默认配置 `EnableListenerEvents=false`。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。
- 未完成外部主机直接访问验收(防火墙未开放);不将 HTTP 鉴权描述为网络加密。
- 远程验证任务已停止并清理;不再自动发送测试消息,后续真机消息验证上限为 3 条。
+1 -1
View File
@@ -30,7 +30,7 @@ try
var serviceOptions = JsonSerializer.Deserialize<ServiceOptions>(
await File.ReadAllTextAsync(GetRequiredOption(args, "--config"), shutdown.Token), ServiceHost.Json)
?? throw new ArgumentException("A service configuration is required.");
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(serviceOptions));
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(serviceOptions, new AccountBindingStore(serviceOptions)));
await app.StartAsync(shutdown.Token);
try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); }
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
+211 -8
View File
@@ -4,13 +4,16 @@ using WxAgent.Windows;
namespace WxAgent.Host;
internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBackend, IAgentEventSource
internal sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingStore bindings) : IAgentBackend, IAgentEventSource
{
private readonly SemaphoreSlim bindingGate = new(1, 1);
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("account-binding", true, false, true, true, true, "manage", false, 30, "Requires an explicit target and verified database/UI identity.", ["docs/WebUI-MCP-开发计划.md"]),
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"),
@@ -33,24 +36,180 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken
public async IAsyncEnumerable<AgentEvent> ListenAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", "listener-checkpoint.json");
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 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: options.ListenerSession))
{
yield return new AgentEvent(item.EventId, "ui-current", item.Session,
yield return new AgentEvent(item.EventId, binding.AccountId, item.Session,
item.Kind.ToString(), item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt);
}
}
}
public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken)
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, false);
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());
var current = bindings.ReadAll();
var live = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var binding in current)
{
if (!targets.Any(target => target.IsBound && target.ProcessId == binding.ProcessId && target.WindowHandle == binding.WindowHandle)) continue;
try
{
using var scope = WechatChatClient.UseWindowTarget(binding.ProcessId, binding.WindowHandle);
if (BindingMatches(binding, await WechatChatClient.GetMyInfoAsync(cancellationToken))) live.Add(binding.AccountId);
}
catch (WxAgentException) { }
}
return roots.Select(root =>
{
var binding = bindings.Get(root.Fingerprint);
var isLive = binding is not null && live.Contains(binding.AccountId);
return new AccountInfo(root.Fingerprint, null, null, null, root.Fingerprint, isLive, binding,
binding is null ? "Unbound" : isLive ? "Bound" : "Stale");
}).ToArray();
}
public Task<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken cancellationToken) =>
ReadUiTargetsAsync(cancellationToken, true);
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, false)).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;
using (WechatChatClient.UseWindowTarget(processId, windowHandle))
ui = await WechatChatClient.GetMyInfoAsync(cancellationToken);
var databaseIdentity = 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 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 (!page.HasMore && exact.Length == 1)
return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName ?? exact[0].Remark);
}
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 (!page.HasMore && exact.Length == 1)
return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName ?? exact[0].Remark);
}
return null;
}
catch (WxAgentException) { return null; }
}
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)
@@ -59,18 +218,27 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken
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();
@@ -79,6 +247,9 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken
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);
@@ -86,6 +257,9 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken
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))
@@ -99,8 +273,37 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken
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));
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);
@@ -152,7 +355,7 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken
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,
activeAccountBound = bindings.ReadAll().Count != 0,
defaultReadOnly = true
});
}
@@ -0,0 +1,42 @@
using System.Text.Json;
namespace WxAgent.Service;
public sealed record AccountBinding(string AccountId, int ProcessId, long WindowHandle, string? WechatId, string Nickname, DateTimeOffset BoundAt);
public sealed record UiTargetInfo(string TargetId, int ProcessId, long WindowHandle, string? Title, string? WechatId, string? Nickname, bool IsBound);
public sealed class AccountBindingStore(ServiceOptions options)
{
private readonly string path = Path.Combine(options.DataDirectory, "account-bindings.json");
private readonly object gate = new();
public IReadOnlyList<AccountBinding> ReadAll()
{
lock (gate)
{
if (!File.Exists(path)) return [];
try { return JsonSerializer.Deserialize<AccountBinding[]>(File.ReadAllText(path), ServiceHost.Json) ?? []; }
catch (JsonException) { return []; }
}
}
public AccountBinding? Get(string accountId)
{
var matches = ReadAll().Where(x => string.Equals(x.AccountId, accountId, StringComparison.OrdinalIgnoreCase)).ToArray();
return matches.Length == 1 ? matches[0] : null;
}
public void Replace(IEnumerable<AccountBinding> bindings)
{
var items = bindings.GroupBy(x => x.AccountId, StringComparer.OrdinalIgnoreCase).Select(x => x.Last()).ToArray();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
lock (gate)
{
var temp = path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(items, ServiceHost.Json));
File.Move(temp, path, true);
}
}
public void Remove(string accountId) => Replace(ReadAll().Where(x => !string.Equals(x.AccountId, accountId, StringComparison.OrdinalIgnoreCase)));
}
+66 -14
View File
@@ -13,19 +13,28 @@ public interface IAgentBackend
Task<object> StatusAsync(CancellationToken cancellationToken);
Task<object> DiagnoseAsync(CancellationToken cancellationToken) => StatusAsync(cancellationToken);
Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<AccountInfo>>([]);
Task<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<UiTargetInfo>>([]);
Task<AccountBinding> BindAccountAsync(string accountId, string targetId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available.");
Task UnbindAccountAsync(string accountId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available.");
Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<SessionInfo>>([]);
Task<IReadOnlyList<SessionInfo>> SessionsAsync(string? accountId, CancellationToken cancellationToken) => SessionsAsync(cancellationToken);
Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<SessionSearchInfo>>([]);
Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) => SearchSessionsAsync(query, exactOnly, cancellationToken);
Task<SessionInfo?> CurrentSessionAsync(CancellationToken cancellationToken) => Task.FromResult<SessionInfo?>(null);
Task<SessionInfo?> CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) => CurrentSessionAsync(cancellationToken);
Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Session opening is not available.");
Task<SessionInfo> OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) => OpenSessionAsync(automationId, cancellationToken);
Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) => Task.FromResult(new SessionViewportInfo(0, false, []));
Task<SessionViewportInfo> ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => ScrollSessionsAsync(direction, pages, cancellationToken);
Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<MessageInfo>>([]);
Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) => MessagesAsync(session, includeContent, cancellationToken);
Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page<ContactInfo>([], limit, offset, false, null));
Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page<GroupMemberInfo>([], limit, offset, false, null));
Task<IReadOnlyList<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<DatabaseMessageInfo>>([]);
Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Merged database messages are not available.");
}
public sealed class AgentService(IAgentBackend backend, ServiceSecurity security, IHttpContextAccessor contexts, OperationQueue operations, ArtifactStore artifacts)
public sealed class AgentService(IAgentBackend backend, ServiceSecurity security, IHttpContextAccessor contexts, OperationQueue operations, ArtifactStore artifacts, AccountBindingStore bindings)
{
public ServiceIdentity Identity => contexts.HttpContext?.Items[typeof(ServiceIdentity)] as ServiceIdentity
?? throw new ServiceException("Unauthorized", 401, "Authentication required.");
@@ -52,32 +61,75 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security
}
public async Task<Page<AccountInfo>> AccountsAsync(int limit, int offset, CancellationToken ct)
{ RequireCapability("accounts-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.AccountsAsync(ct)).ToPage(limit, offset); }
{
RequireCapability("accounts-list"); ReadOnlyRequest.Page(limit, offset);
var accounts = (await backend.AccountsAsync(ct)).Select(account =>
{
var binding = bindings.Get(account.AccountId);
return account with { Binding = binding, IsUiBindingKnown = account.BindingStatus == "Bound" };
}).ToArray();
return accounts.ToPage(limit, offset);
}
public async Task<Page<SessionInfo>> SessionsAsync(int limit, int offset, CancellationToken ct)
{ RequireCapability("sessions-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.SessionsAsync(ct)).ToPage(limit, offset); }
public IReadOnlyList<AccountBinding> Bindings()
{ security.RequireCurrent(Identity, "read"); return bindings.ReadAll(); }
public async Task<Page<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, int limit, int offset, CancellationToken ct)
{ RequireCapability("sessions-search"); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(query) || query.Length > 200) throw new ServiceException("InvalidRequest", 400, "query must be 1..200 characters."); return (await backend.SearchSessionsAsync(query, exactOnly, ct)).ToPage(limit, offset); }
public async Task<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken ct)
{ security.RequireCurrent(Identity, "read"); return await backend.UiTargetsAsync(ct); }
public async Task<SessionInfo> CurrentSessionAsync(CancellationToken ct)
{ RequireCapability("session-current"); return await backend.CurrentSessionAsync(ct) ?? throw new ServiceException("NotFound", 404, "No current session."); }
public async Task<AccountBinding> BindAccountAsync(string accountId, string targetId, CancellationToken ct)
{
RequireCapability("account-binding");
if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200 || string.IsNullOrWhiteSpace(targetId) || targetId.Length > 200)
throw new ServiceException("InvalidRequest", 400, "accountId and targetId are required and bounded.");
return await backend.BindAccountAsync(accountId, targetId, ct);
}
public async Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken ct)
{ RequireCapability("session-open"); if (string.IsNullOrWhiteSpace(automationId) || automationId.Length > 512) throw new ServiceException("InvalidRequest", 400, "automationId is required and bounded."); return await backend.OpenSessionAsync(automationId, ct); }
public async Task UnbindAccountAsync(string accountId, CancellationToken ct)
{
RequireCapability("account-binding");
if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200)
throw new ServiceException("InvalidRequest", 400, "accountId is required and bounded.");
await backend.UnbindAccountAsync(accountId, ct);
}
public async Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken ct)
{ RequireCapability("sessions-scroll"); if (pages is < 1 or > 10 || direction is not ("up" or "down")) throw new ServiceException("InvalidRequest", 400, "direction must be up/down and pages must be 1..10."); return await backend.ScrollSessionsAsync(direction, pages, ct); }
public async Task<Page<SessionInfo>> SessionsAsync(string? accountId, int limit, int offset, CancellationToken ct)
{ RequireCapability("sessions-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.SessionsAsync(accountId, ct)).ToPage(limit, offset); }
public async Task<Page<MessageInfo>> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct)
public Task<Page<SessionInfo>> SessionsAsync(int limit, int offset, CancellationToken ct) => SessionsAsync(null, limit, offset, ct);
public async Task<Page<SessionSearchInfo>> SearchSessionsAsync(string? accountId, string query, bool exactOnly, int limit, int offset, CancellationToken ct)
{ RequireCapability("sessions-search"); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(query) || query.Length > 200) throw new ServiceException("InvalidRequest", 400, "query must be 1..200 characters."); return (await backend.SearchSessionsAsync(accountId, query, exactOnly, ct)).ToPage(limit, offset); }
public Task<Page<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, int limit, int offset, CancellationToken ct) => SearchSessionsAsync(null, query, exactOnly, limit, offset, ct);
public async Task<SessionInfo> CurrentSessionAsync(string? accountId, CancellationToken ct)
{ RequireCapability("session-current"); return await backend.CurrentSessionAsync(accountId, ct) ?? throw new ServiceException("NotFound", 404, "No current session."); }
public Task<SessionInfo> CurrentSessionAsync(CancellationToken ct) => CurrentSessionAsync(null, ct);
public async Task<SessionInfo> OpenSessionAsync(string? accountId, string automationId, CancellationToken ct)
{ RequireCapability("session-open"); if (string.IsNullOrWhiteSpace(automationId) || automationId.Length > 512) throw new ServiceException("InvalidRequest", 400, "automationId is required and bounded."); return await backend.OpenSessionAsync(accountId, automationId, ct); }
public Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken ct) => OpenSessionAsync(null, automationId, ct);
public async Task<SessionViewportInfo> ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken ct)
{ RequireCapability("sessions-scroll"); if (pages is < 1 or > 10 || direction is not ("up" or "down")) throw new ServiceException("InvalidRequest", 400, "direction must be up/down and pages must be 1..10."); return await backend.ScrollSessionsAsync(accountId, direction, pages, ct); }
public Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken ct) => ScrollSessionsAsync(null, direction, pages, ct);
public async Task<Page<MessageInfo>> MessagesAsync(string? accountId, string? session, int limit, int offset, bool includeContent, CancellationToken ct)
{
RequireCapability("messages-read");
var identity = security.RequireCurrent(Identity, includeContent ? "content" : "read");
ReadOnlyRequest.Page(limit, offset);
var values = await backend.MessagesAsync(session, includeContent && identity.Allows("content"), ct);
var values = await backend.MessagesAsync(accountId, session, includeContent && identity.Allows("content"), ct);
return values.ToPage(limit, offset);
}
public Task<Page<MessageInfo>> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) =>
MessagesAsync(null, session, limit, offset, includeContent, ct);
public async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken ct)
{
RequireCapability("contacts-list");
+20 -11
View File
@@ -18,23 +18,32 @@ public sealed class AgentTools(AgentService service)
[McpServerTool(Name = "agent_diagnose", ReadOnly = true), Description("Run redacted diagnostics without changing the WeChat UI.")]
public Task<CallToolResult> Diagnose(CancellationToken cancellationToken = default) => Result(async () => await service.DiagnoseAsync(cancellationToken));
[McpServerTool(Name = "accounts_list", ReadOnly = true), Description("List explicit database accounts; a fingerprint does not prove current UI identity.")]
[McpServerTool(Name = "accounts_list", ReadOnly = true), Description("List database accounts and explicit binding state; a fingerprint does not prove current UI identity.")]
public Task<CallToolResult> Accounts(int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.AccountsAsync(limit, offset, cancellationToken));
[McpServerTool(Name = "sessions_list", ReadOnly = true), Description("List the visible WeChat sessions.")]
public Task<CallToolResult> Sessions(int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SessionsAsync(limit, offset, cancellationToken));
[McpServerTool(Name = "ui_targets", ReadOnly = true), Description("List current visible WeChat window targets for explicit account binding.")]
public Task<CallToolResult> UiTargets(CancellationToken cancellationToken = default) => Result(async () => await service.UiTargetsAsync(cancellationToken));
[McpServerTool(Name = "sessions_search", ReadOnly = true), Description("Search visible sessions; exact targeting is explicit and ambiguous matches are not guessed.")]
public Task<CallToolResult> SearchSessions(string query, bool exactOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SearchSessionsAsync(query, exactOnly, limit, offset, cancellationToken));
[McpServerTool(Name = "account_bind"), Description("Explicitly bind one verified database account to one current WeChat window after identity verification.")]
public Task<CallToolResult> BindAccount(string accountId, string targetId, CancellationToken cancellationToken = default) => Result(async () => await service.BindAccountAsync(accountId, targetId, cancellationToken));
[McpServerTool(Name = "session_current", ReadOnly = true), Description("Read the current visible WeChat session.")]
public Task<CallToolResult> CurrentSession(CancellationToken cancellationToken = default) => Result(async () => await service.CurrentSessionAsync(cancellationToken));
[McpServerTool(Name = "account_unbind"), Description("Remove one explicit account-to-window binding; it does not affect WeChat or database data.")]
public Task<CallToolResult> UnbindAccount(string accountId, CancellationToken cancellationToken = default) => Result(async () => { await service.UnbindAccountAsync(accountId, cancellationToken); return new { accountId, unbound = true }; });
[McpServerTool(Name = "messages_read", ReadOnly = true), Description("Read bounded visible messages; content requires the content permission.")]
public Task<CallToolResult> Messages(string? session = null, int limit = 50, int offset = 0, bool includeContent = false, CancellationToken cancellationToken = default) => Result(async () => await service.MessagesAsync(session, limit, offset, includeContent, cancellationToken));
[McpServerTool(Name = "sessions_list", ReadOnly = true), Description("List visible sessions for an explicitly bound account.")]
public Task<CallToolResult> Sessions(string accountId, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SessionsAsync(accountId, limit, offset, cancellationToken));
[McpServerTool(Name = "contacts_list", ReadOnly = true), Description("Read stable-ID contacts from the selected read-only database account.")]
public Task<CallToolResult> Contacts(string? accountId = null, string? contains = null, bool? groupsOnly = null, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.ContactsAsync(accountId, contains, groupsOnly, limit, offset, cancellationToken));
[McpServerTool(Name = "sessions_search", ReadOnly = true), Description("Search sessions for an explicitly bound account; ambiguous matches are not guessed.")]
public Task<CallToolResult> SearchSessions(string query, string accountId, bool exactOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SearchSessionsAsync(accountId, query, exactOnly, limit, offset, cancellationToken));
[McpServerTool(Name = "session_current", ReadOnly = true), Description("Read the current session for an explicitly bound account.")]
public Task<CallToolResult> CurrentSession(string accountId, CancellationToken cancellationToken = default) => Result(async () => await service.CurrentSessionAsync(accountId, cancellationToken));
[McpServerTool(Name = "messages_read", ReadOnly = true), Description("Read bounded visible messages for an explicitly bound account; content requires the content permission.")]
public Task<CallToolResult> Messages(string accountId, string? session = null, int limit = 50, int offset = 0, bool includeContent = false, CancellationToken cancellationToken = default) => Result(async () => await service.MessagesAsync(accountId, session, limit, offset, includeContent, cancellationToken));
[McpServerTool(Name = "contacts_list", ReadOnly = true), Description("Read stable-ID contacts from an explicitly selected read-only database account.")]
public Task<CallToolResult> Contacts(string accountId, string? contains = null, bool? groupsOnly = null, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.ContactsAsync(accountId, contains, groupsOnly, limit, offset, cancellationToken));
[McpServerTool(Name = "group_members", ReadOnly = true), Description("Read stable-ID members of an explicitly selected group and account.")]
public Task<CallToolResult> GroupMembers(string accountId, string group, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.GroupMembersAsync(accountId, group, limit, offset, cancellationToken));
+1 -1
View File
@@ -1,7 +1,7 @@
namespace WxAgent.Service;
public sealed record Page<T>(IReadOnlyList<T> Items, int Limit, int Offset, bool HasMore, int? NextOffset);
public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown);
public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown, AccountBinding? Binding = null, string BindingStatus = "Unbound");
public sealed record SessionInfo(string Name, string AutomationId, bool IsCurrent);
public sealed record MessageInfo(string Fingerprint, string Type, string? Sender, string? Summary, string? Content);
public sealed record ListRequest(int Limit = 50, int Offset = 0, bool IncludeContent = false, string? AccountId = null, string? Session = null);
+15 -8
View File
@@ -30,6 +30,7 @@ public static class ServiceHost
builder.Services.AddSingleton<OperationStore>();
builder.Services.AddSingleton<EventHub>();
builder.Services.AddSingleton<ArtifactStore>();
builder.Services.AddSingleton<AccountBindingStore>();
builder.Services.AddSingleton<OperationQueue>();
builder.Services.AddHostedService(p => p.GetRequiredService<OperationQueue>());
builder.Services.AddHostedService<EventPump>();
@@ -81,12 +82,16 @@ public static class ServiceHost
app.MapGet("/api/v1/diagnostics", (AgentService service, CancellationToken ct) => service.DiagnoseAsync(ct));
app.MapGet("/api/v1/capabilities", (AgentService service) => service.Capabilities());
app.MapGet("/api/v1/accounts", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.AccountsAsync(limit ?? 50, offset ?? 0, ct));
app.MapGet("/api/v1/sessions", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.SessionsAsync(limit ?? 50, offset ?? 0, ct));
app.MapGet("/api/v1/sessions/search", (string query, bool? exactOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SearchSessionsAsync(query, exactOnly ?? false, limit ?? 50, offset ?? 0, ct));
app.MapGet("/api/v1/sessions/current", (AgentService service, CancellationToken ct) => service.CurrentSessionAsync(ct));
app.MapPost("/api/v1/sessions/scroll", (ScrollRequest request, AgentService service, CancellationToken ct) => service.ScrollSessionsAsync(request.Direction, request.Pages, ct));
app.MapPost("/api/v1/sessions/open", (OpenSessionRequest request, AgentService service, CancellationToken ct) => service.OpenSessionAsync(request.AutomationId, ct));
app.MapGet("/api/v1/messages", (string? session, int? limit, int? offset, bool? includeContent, AgentService service, CancellationToken ct) => service.MessagesAsync(session, limit ?? 50, offset ?? 0, includeContent ?? false, ct));
app.MapGet("/api/v1/accounts/bindings", (AgentService service) => service.Bindings());
app.MapPost("/api/v1/accounts/bind", (BindAccountRequest request, AgentService service, CancellationToken ct) => service.BindAccountAsync(request.AccountId, request.TargetId, ct));
app.MapPost("/api/v1/accounts/unbind", async (UnbindAccountRequest request, AgentService service, CancellationToken ct) => { await service.UnbindAccountAsync(request.AccountId, ct); return Results.NoContent(); });
app.MapGet("/api/v1/ui-targets", (AgentService service, CancellationToken ct) => service.UiTargetsAsync(ct));
app.MapGet("/api/v1/sessions", (string? accountId, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SessionsAsync(accountId, limit ?? 50, offset ?? 0, ct));
app.MapGet("/api/v1/sessions/search", (string? accountId, string query, bool? exactOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SearchSessionsAsync(accountId, query, exactOnly ?? false, limit ?? 50, offset ?? 0, ct));
app.MapGet("/api/v1/sessions/current", (string? accountId, AgentService service, CancellationToken ct) => service.CurrentSessionAsync(accountId, ct));
app.MapPost("/api/v1/sessions/scroll", (ScrollRequest request, AgentService service, CancellationToken ct) => service.ScrollSessionsAsync(request.AccountId, request.Direction, request.Pages, ct));
app.MapPost("/api/v1/sessions/open", (OpenSessionRequest request, AgentService service, CancellationToken ct) => service.OpenSessionAsync(request.AccountId, request.AutomationId, ct));
app.MapGet("/api/v1/messages", (string? accountId, string? session, int? limit, int? offset, bool? includeContent, AgentService service, CancellationToken ct) => service.MessagesAsync(accountId, session, limit ?? 50, offset ?? 0, includeContent ?? false, ct));
app.MapGet("/api/v1/contacts", (string? accountId, string? contains, bool? groupsOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.ContactsAsync(accountId, contains, groupsOnly, limit ?? 50, offset ?? 0, ct));
app.MapGet("/api/v1/groups/{accountId}/{group}/members", (string accountId, string group, int? limit, int? offset, AgentService service, CancellationToken ct) => service.GroupMembersAsync(accountId, group, limit ?? 50, offset ?? 0, ct));
app.MapGet("/api/v1/db/messages", (string accountId, string chatId, int? limit, int? offset, long? localId, AgentService service, CancellationToken ct) => service.DatabaseMessagesAsync(accountId, chatId, limit ?? 50, offset ?? 0, localId, ct));
@@ -151,6 +156,8 @@ public static class ServiceHost
}
public sealed record LoginRequest(string Token);
public sealed record ScrollRequest(string Direction, int Pages = 1);
public sealed record OpenSessionRequest(string AutomationId);
public sealed record ScrollRequest(string? AccountId, string Direction, int Pages = 1);
public sealed record OpenSessionRequest(string? AccountId, string AutomationId);
public sealed record BindAccountRequest(string AccountId, string TargetId);
public sealed record UnbindAccountRequest(string AccountId);
}
+11 -2
View File
@@ -1,7 +1,16 @@
const $=id=>document.getElementById(id);let csrf='';
function showError(e){$('error').textContent=e.message||'请求失败'}
async function api(path,init={}){const r=await fetch(path,{...init,headers:{'Accept':'application/json',...(init.headers||{})}});if(!r.ok){let x={};try{x=await r.json()}catch{}if(r.status===401){$('app').hidden=true;$('login').hidden=false}throw new Error(x.error?.message||`HTTP ${r.status}`)}return r.status===204?null:r.json()}
async function mutate(path,body){return api(path,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf,'Origin':location.origin},body:JSON.stringify(body)})}
function rows(id,items,format){$(id).replaceChildren(...items.map(x=>{const d=document.createElement('div');d.className='row';d.textContent=format(x);return d}))}
async function refresh(){try{const [s,c,a,se,co,m]=await Promise.all([api('/api/v1/status'),api('/api/v1/capabilities'),api('/api/v1/accounts?limit=50'),api('/api/v1/sessions?limit=50'),api('/api/v1/contacts?limit=50'),api(`/api/v1/messages?limit=50&includeContent=${$('content').checked}`)]);$('status').textContent=JSON.stringify(s,null,2);rows('capabilities',c,x=>`${x.operation} — ${x.enabled?'可用':'禁用'}${x.disabledReason?`:${x.disabledReason}`:''}`);rows('accounts',a.items,x=>`${x.accountId} ${x.displayName||''}(数据库指纹不代表 UI 绑定)`);rows('sessions',se.items,x=>`${x.name}${x.isCurrent?'(当前)':''}`);rows('contacts',co.items,x=>`${x.displayName||'[未知]'} — ${x.id}`);rows('messages',m.items,x=>`${x.type} ${x.sender||''}: ${x.content??x.summary??'[正文未授权]'}`)}catch(e){showError(e)}}
function renderBindings(accounts,targets){
const accountById=new Map(accounts.map(x=>[x.accountId,x]));
const accountRows=accounts.map(account=>{const d=document.createElement('div');d.className='row';d.textContent=`${account.accountId} ${account.bindingStatus|| (account.binding?'Bound':'Unbound')} ${account.binding?`进程 ${account.binding.processId} 窗口 ${account.binding.windowHandle}`:''}`;if(account.binding){const b=document.createElement('button');b.textContent='解绑';b.onclick=async()=>{try{await mutate('/api/v1/accounts/unbind',{accountId:account.accountId});await refresh()}catch(e){showError(e)}};d.append(' ',b)}return d});
$('accounts').replaceChildren(...accountRows);
const targetRows=targets.map(target=>{const d=document.createElement('div');d.className='row';d.textContent=`${target.targetId} ${target.wechatId||target.nickname||'[身份未读取]'}(PID ${target.processId} / HWND ${target.windowHandle})`;if(target.isBound){d.append(' 已绑定')}else{const select=document.createElement('select');select.append(new Option('选择账号',''),...accounts.filter(x=>!x.binding).map(x=>new Option(x.accountId,x.accountId)));const b=document.createElement('button');b.textContent='绑定';b.onclick=async()=>{if(!select.value)return;try{await mutate('/api/v1/accounts/bind',{accountId:select.value,targetId:target.targetId});await refresh()}catch(e){showError(e)}};d.append(' ',select,' ',b)}return d});
$('uiTargets').replaceChildren(...targetRows);
if(!accountById.size&&!targets.length){$('uiTargets').textContent='未发现当前交互式会话中的微信主窗口。'}
}
async function refresh(){try{const [s,c,a,t]=await Promise.all([api('/api/v1/status'),api('/api/v1/capabilities'),api('/api/v1/accounts?limit=50'),api('/api/v1/ui-targets')]);$('status').textContent=JSON.stringify(s,null,2);rows('capabilities',c,x=>`${x.operation} — ${x.enabled?'可用':'禁用'}${x.disabledReason?`:${x.disabledReason}`:''}`);const select=$('accountSelect');const old=select.value;select.replaceChildren(new Option('选择已绑定账号',''),...a.items.filter(x=>x.binding).map(x=>new Option(x.accountId,x.accountId)));select.value=a.items.some(x=>x.accountId===old&&x.binding)?old:'';renderBindings(a.items,t);if(!select.value){rows('sessions',[] ,()=> '');rows('contacts',[] ,()=> '');rows('messages',[] ,()=> '');return}const id=`&accountId=${encodeURIComponent(select.value)}`;const [se,co,m]=await Promise.all([api(`/api/v1/sessions?limit=50${id}`),api(`/api/v1/contacts?limit=50${id}`),api(`/api/v1/messages?limit=50&includeContent=${$('content').checked}${id}`)]);rows('sessions',se.items,x=>`${x.name}${x.isCurrent?'(当前)':''}`);rows('contacts',co.items,x=>`${x.displayName||'[未知]'} — ${x.id}`);rows('messages',m.items,x=>`${x.type} ${x.sender||''}: ${x.content??x.summary??'[正文未授权]'}`)}catch(e){showError(e)}}
$('loginForm').addEventListener('submit',async e=>{e.preventDefault();try{const x=await api('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json','Origin':location.origin},body:JSON.stringify({token:$('token').value})});csrf=x.csrfToken;$('token').value='';$('login').hidden=true;$('app').hidden=false;await refresh()}catch(e){showError(e)}});
$('refresh').onclick=refresh;$('content').onchange=refresh;$('lookup').onclick=async()=>{try{$('operation').textContent=JSON.stringify(await api('/api/v1/operations/'+encodeURIComponent($('operationId').value)),null,2)}catch(e){showError(e)}};$('logout').onclick=async()=>{try{await api('/api/v1/logout',{method:'POST',headers:{'X-CSRF-Token':csrf,'Origin':location.origin}})}finally{$('app').hidden=true;$('login').hidden=false}};
$('refresh').onclick=refresh;$('accountSelect').onchange=refresh;$('content').onchange=refresh;$('lookup').onclick=async()=>{try{$('operation').textContent=JSON.stringify(await api('/api/v1/operations/'+encodeURIComponent($('operationId').value)),null,2)}catch(e){showError(e)}};$('logout').onclick=async()=>{try{await api('/api/v1/logout',{method:'POST',headers:{'X-CSRF-Token':csrf,'Origin':location.origin}})}finally{$('app').hidden=true;$('login').hidden=false}};
+2 -2
View File
@@ -2,5 +2,5 @@
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WxAgent</title><link rel="stylesheet" href="styles.css"></head>
<body><main><h1>WxAgent 控制台</h1><p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
<section id="login"><h2>登录</h2><form id="loginForm"><label>Token <input id="token" type="password" autocomplete="off" required></label><button>登录</button></form></section>
<section id="app" hidden><div class="toolbar"><button id="refresh">刷新状态</button><button id="logout">退出</button></div><pre id="status" role="status"></pre>
<h2>能力</h2><div id="capabilities"></div><h2>账号</h2><div id="accounts"></div><h2>会话</h2><div id="sessions"></div><h2>联系人/群</h2><div id="contacts"></div><h2>任务</h2><label>Operation ID <input id="operationId" autocomplete="off"><button id="lookup">查询</button></label><pre id="operation"></pre><h2>可见消息</h2><label><input id="content" type="checkbox"> 显示正文(需要 content 权限)</label><div id="messages"></div><p id="error" class="error" role="alert"></p></section></main><script type="module" src="app.js"></script></body></html>
<section id="app" hidden><div class="toolbar"><button id="refresh">刷新状态</button><button id="logout">退出</button><label>当前账号 <select id="accountSelect"><option value="">选择账号</option></select></label></div><pre id="status" role="status"></pre>
<h2>能力</h2><div id="capabilities"></div><h2>账号</h2><div id="accounts"></div><h2>微信窗口 / 显式绑定</h2><div id="uiTargets"></div><h2>会话</h2><div id="sessions"></div><h2>联系人/群</h2><div id="contacts"></div><h2>任务</h2><label>Operation ID <input id="operationId" autocomplete="off"><button id="lookup">查询</button></label><pre id="operation"></pre><h2>可见消息</h2><label><input id="content" type="checkbox"> 显示正文(需要 content 权限)</label><div id="messages"></div><p id="error" class="error" role="alert"></p></section></main><script type="module" src="app.js"></script></body></html>
+3 -1
View File
@@ -13,6 +13,8 @@ namespace WxAgent.Windows;
public static partial class WechatChatClient
{
public static IDisposable UseWindowTarget(int processId, long windowHandle) => WechatDoctor.UseWindow(processId, windowHandle);
private static readonly InterprocessCommandGate CommandQueue = CreateCommandGate();
private static InterprocessCommandGate CreateCommandGate()
@@ -577,7 +579,7 @@ public static partial class WechatChatClient
if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null)
{
cancellationToken.ThrowIfCancellationRequested();
if (!WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, cancellationToken);
if (WechatDoctor.TargetProcessId is null && !WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, cancellationToken);
for (var attempt = 0; attempt < 50; attempt++)
{
if (cancellationToken.WaitHandle.WaitOne(100)) cancellationToken.ThrowIfCancellationRequested();
+29
View File
@@ -118,10 +118,39 @@ public static class WechatDoctor
inputDesktopAvailable);
}
private static readonly AsyncLocal<int?> TargetProcess = new();
private static readonly AsyncLocal<long?> TargetWindow = new();
internal static int? TargetProcessId => TargetProcess.Value;
internal static IDisposable UseWindow(int processId, long windowHandle) => UseTarget(processId, windowHandle);
private static IDisposable UseTarget(int? processId, long? windowHandle)
{
var previousProcess = TargetProcess.Value;
var previousWindow = TargetWindow.Value;
TargetProcess.Value = processId;
TargetWindow.Value = windowHandle;
return new TargetScope(previousProcess, previousWindow);
}
private sealed class TargetScope(int? previousProcess, long? previousWindow) : IDisposable
{
public void Dispose()
{
TargetProcess.Value = previousProcess;
TargetWindow.Value = previousWindow;
}
}
internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation)
{
FlaUI.Core.AutomationElements.AutomationElement? fallback = null;
var processId = TargetProcess.Value;
var windowHandle = TargetWindow.Value;
var mainWindows = WechatNativeWindow.Enumerate().Where(window =>
(processId is null || window.ProcessId == processId) &&
(windowHandle is null || window.Handle == windowHandle) &&
window.Visible && !window.Minimized && WechatLocators.IsNativeMainWindow(window.ClassName, window.Title));
foreach (var native in mainWindows)
{
@@ -49,4 +49,8 @@ public static partial class WechatChatClient
{
public static IReadOnlyList<WechatWindowDiagnostic> InspectNativeWindows() =>
WechatNativeWindow.Enumerate().Select(WechatNativeWindow.Sanitize).ToArray();
public static IReadOnlyList<WechatWindowDiagnostic> InspectMainWindows() =>
WechatNativeWindow.Enumerate().Where(window => WechatLocators.IsNativeMainWindow(window.ClassName, window.Title))
.Select(WechatNativeWindow.Sanitize).ToArray();
}
@@ -0,0 +1,30 @@
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class AccountBindingStoreTests
{
[Fact]
public void ReplacesByAccountAndRemovesExplicitBinding()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
try
{
var store = new AccountBindingStore(new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "credentials.json") });
store.Replace([
new AccountBinding("a", 10, 20, "wxid-a", "Alice", DateTimeOffset.UtcNow),
new AccountBinding("a", 11, 21, "wxid-a", "Alice", DateTimeOffset.UtcNow),
new AccountBinding("b", 12, 22, "wxid-b", "Bob", DateTimeOffset.UtcNow)
]);
Assert.Equal(11, store.Get("a")!.ProcessId);
Assert.Equal(2, store.ReadAll().Count);
store.Remove("a");
Assert.Null(store.Get("a"));
Assert.Single(store.ReadAll());
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
}