refactor: split node agent and Go control plane

This commit is contained in:
2026-09-11 16:57:31 +08:00
parent 078c22c73b
commit c86ba9c4d7
129 changed files with 654 additions and 77 deletions
@@ -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)));
}
+215
View File
@@ -0,0 +1,215 @@
using Microsoft.AspNetCore.Http;
using WxAgent.Core;
namespace WxAgent.Service;
public sealed record AgentCapability(string Operation, bool Implemented, bool Validated, bool Enabled,
bool RequiresUi, bool HasSideEffects, string Permission, bool RequiresConfirmation, int TimeoutSeconds,
string? DisabledReason, string[] Evidence, string? WechatVersion = null, int ContractVersion = 1);
// The only platform seam: Windows is composed by Host; route tests load this assembly unchanged.
public interface IAgentBackend
{
IReadOnlyList<AgentCapability> Capabilities { get; }
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 SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Text sending is not available.");
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, AccountBindingStore bindings)
{
public ServiceIdentity Identity => contexts.HttpContext?.Items[typeof(ServiceIdentity)] as ServiceIdentity
?? throw new ServiceException("Unauthorized", 401, "Authentication required.");
internal void RequireEvents() => RequireCapability("listener-events");
private AgentCapability RequireCapability(string operation)
{
var capability = backend.Capabilities.SingleOrDefault(c => c.Operation == operation)
?? throw new ServiceException("Unsupported", 501, "Capability is not implemented.");
security.RequireCurrent(Identity, capability.Permission);
if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability is disabled.");
return capability;
}
public async Task<object> DiagnoseAsync(CancellationToken cancellationToken)
{ RequireCapability("agent-diagnose"); return await backend.DiagnoseAsync(cancellationToken); }
public async Task<object> StatusAsync(CancellationToken cancellationToken)
{
RequireCapability("agent-status");
var result = await backend.StatusAsync(cancellationToken);
security.RequireCurrent(Identity, "read");
return result;
}
public async Task<Page<AccountInfo>> AccountsAsync(int limit, int offset, CancellationToken ct)
{
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 IReadOnlyList<AccountBinding> Bindings()
{ security.RequireCurrent(Identity, "read"); return bindings.ReadAll(); }
public async Task<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken ct)
{ security.RequireCurrent(Identity, "read"); return await backend.UiTargetsAsync(ct); }
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 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<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 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(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 OperationRecord SubmitOperation(OperationSubmitRequest request, CancellationToken ct)
{
if (!string.Equals(request.Kind, "send-text", StringComparison.Ordinal))
throw new ServiceException("UnsupportedOperation", 400, "Only send-text is available in this phase.");
var accountId = RequireText(request.AccountId, "accountId", 200);
var targetId = RequireText(request.TargetId, "targetId", 512);
var text = PrepareText(request.Text);
var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128);
if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required.");
var capability = RequireCapability("send-text");
var canonical = System.Text.Json.JsonSerializer.Serialize(new { request.Kind, accountId, targetId, text });
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
}
private static string RequireText(string? value, string name, int maxLength)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > maxLength)
throw new ServiceException("InvalidRequest", 400, $"{name} is required and bounded.");
return value;
}
private static string PrepareText(string? value)
{
try { return WechatTextInput.Prepare(value ?? string.Empty); }
catch (WxAgentException exception) { throw new ServiceException("InvalidRequest", 400, exception.Message); }
}
public async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken ct)
{
RequireCapability("contacts-list");
var identity = security.RequireCurrent(Identity, "read");
ReadOnlyRequest.Page(limit, offset); if (!string.IsNullOrWhiteSpace(accountId)) security.RequireCurrent(identity, "read", accountId);
return await backend.ContactsAsync(accountId, contains, groupsOnly, limit, offset, ct);
}
public async Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken ct)
{
RequireCapability("group-members");
var identity = security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset);
return await backend.GroupMembersAsync(accountId, group, limit, offset, ct);
}
public async Task<Page<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, int offset, long? localId, CancellationToken ct)
{
RequireCapability("db-messages"); security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset);
if (string.IsNullOrWhiteSpace(chatId) || chatId.Length > 512) throw new ServiceException("InvalidRequest", 400, "chatId is required and bounded.");
return (await backend.DatabaseMessagesAsync(accountId, chatId, Math.Min(500, limit + offset), localId, ct)).ToPage(limit, offset);
}
public async Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken ct)
{
RequireCapability("db-merged"); security.RequireCurrent(Identity, "read", accountId);
if (localId <= 0) throw new ServiceException("InvalidRequest", 400, "localId must be positive.");
return await backend.DatabaseMergedAsync(accountId, chatId, localId, ct);
}
public async Task<ArtifactInfo> UploadAsync(IFormFile file, CancellationToken ct)
{ security.RequireCurrent(Identity, "write"); return await artifacts.SaveAsync(Identity.PrincipalId, file, ct); }
public FileStream Download(string id)
{ security.RequireCurrent(Identity, "content"); return artifacts.Open(Identity.PrincipalId, id); }
public Page<OperationSummary> Operations(string? accountId, int limit, int offset)
{
ReadOnlyRequest.Page(limit, offset);
if (accountId is { Length: > 200 }) throw new ServiceException("InvalidRequest", 400, "accountId is too long.");
return operations.List(Identity, accountId, limit, offset);
}
public OperationRecord Operation(string id) => operations.Get(Identity, id);
public OperationRecord CancelOperation(string id) => operations.Cancel(Identity, id);
public IReadOnlyList<AgentCapability> Capabilities()
{
var identity = security.RequireCurrent(Identity, "read");
return backend.Capabilities.Select(c => identity.Allows(c.Permission) ? c :
c with { Enabled = false, DisabledReason = "Permission required." }).ToArray();
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace WxAgent.Service;
[McpServerToolType]
public sealed class AgentTools(AgentService service)
{
[McpServerTool(Name = "agent_status", ReadOnly = true), Description("Read authenticated agent and WeChat availability. Service online does not imply WeChat available.")]
public Task<CallToolResult> Status(CancellationToken cancellationToken) =>
Result(() => service.StatusAsync(cancellationToken));
[McpServerTool(Name = "agent_capabilities", ReadOnly = true), Description("Read implemented, validated and enabled capabilities and their limits. Disabled capabilities are not callable tools.")]
public Task<CallToolResult> Capabilities() => Result(() => Task.FromResult<object>(service.Capabilities()));
[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 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 = "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 = "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 = "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 = "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 = "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));
[McpServerTool(Name = "db_messages", ReadOnly = true), Description("Read bounded SQLCipher database messages for an explicitly verified account fingerprint; disabled until key scope is validated.")]
public Task<CallToolResult> DatabaseMessages(string accountId, string chatId, int limit = 50, int offset = 0, long? localId = null, CancellationToken cancellationToken = default) => Result(async () => await service.DatabaseMessagesAsync(accountId, chatId, limit, offset, localId, cancellationToken));
[McpServerTool(Name = "db_merged", ReadOnly = true), Description("Read one bounded merged database record for an explicitly verified account fingerprint; disabled until key scope is validated.")]
public Task<CallToolResult> DatabaseMerged(string accountId, string chatId, long localId, CancellationToken cancellationToken = default) => Result(async () => await service.DatabaseMergedAsync(accountId, chatId, localId, cancellationToken));
[McpServerTool(Name = "operations_list", ReadOnly = true), Description("List bounded operations owned by the current identity; accountId is a caller-side view filter, not an authorization boundary.")]
public Task<CallToolResult> Operations(string? accountId = null, int limit = 50, int offset = 0) => Result(() => Task.FromResult<object>(service.Operations(accountId, limit, offset)));
[McpServerTool(Name = "operation_submit"), Description("Submit an explicitly confirmed operation. send-text remains disabled until Windows validation is complete.")]
public Task<CallToolResult> SubmitOperation(string kind, string accountId, string targetId, string? text, string idempotencyKey, bool confirmed = false, CancellationToken cancellationToken = default) =>
Result(() => Task.FromResult<object>(service.SubmitOperation(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken)));
[McpServerTool(Name = "operation_get", ReadOnly = true), Description("Read one operation owned by the current identity; terminal writes are never replayed.")]
public Task<CallToolResult> Operation(string operationId) => Result(() => Task.FromResult<object>(service.Operation(operationId)));
[McpServerTool(Name = "operation_cancel"), Description("Request cancellation of your operation. An in-flight write may remain Unconfirmed; cancellation does not undo a side effect.")]
public Task<CallToolResult> CancelOperation(string operationId) => Result(() => Task.FromResult<object>(service.CancelOperation(operationId)));
internal static async Task<CallToolResult> Result(Func<Task<object>> action)
{
try
{
var value = await action();
return new CallToolResult { Content = [new TextContentBlock { Text = JsonSerializer.Serialize(value, ServiceHost.Json) }] };
}
catch (ServiceException e)
{
return new CallToolResult { IsError = true, Content = [new TextContentBlock
{ Text = JsonSerializer.Serialize(new { error = new { e.Code, e.Message, stage = "validation", retry = false } }, ServiceHost.Json) }] };
}
catch (OperationCanceledException)
{
return new CallToolResult { IsError = true, Content = [new TextContentBlock
{ Text = JsonSerializer.Serialize(new { error = new { code = "Cancelled", message = "Operation cancelled.", stage = "execution", retry = true } }, ServiceHost.Json) }] };
}
catch
{
return new CallToolResult { IsError = true, Content = [new TextContentBlock
{ Text = JsonSerializer.Serialize(new { error = new { code = "InternalError", message = "Tool failed; use the HTTP correlation ID for diagnosis.", stage = "execution", retry = false } }, ServiceHost.Json) }] };
}
}
}
@@ -0,0 +1,58 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
using System.Text.Json;
namespace WxAgent.Service;
public sealed record ArtifactInfo(string ArtifactId, string PrincipalId, string FileName, string ContentType, long Length, string Sha256, DateTimeOffset ExpiresAt);
public sealed class ArtifactStore(ServiceOptions options)
{
private const long MaxBytes = 50L * 1024 * 1024;
private readonly string root = Path.Combine(options.DataDirectory, "artifacts");
private static readonly HashSet<string> Types = new(StringComparer.OrdinalIgnoreCase)
{ ".txt", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip" };
public async Task<ArtifactInfo> SaveAsync(string principalId, IFormFile file, CancellationToken ct)
{
if (file.Length is <= 0 or > MaxBytes) throw new ServiceException("FileTooLarge", 413, "Single file limit is 50 MiB.");
var extension = Path.GetExtension(file.FileName);
if (!Types.Contains(extension)) throw new ServiceException("FileTypeRejected", 415, "File type is not allowed.");
Directory.CreateDirectory(root);
var id = Guid.NewGuid().ToString("N");
var path = Path.Combine(root, id + ".bin");
var metadataPath = Path.Combine(root, id + ".json");
try
{
await using var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
await using var input = file.OpenReadStream();
var buffer = new byte[81920]; long total = 0; int read;
while ((read = await input.ReadAsync(buffer, ct)) != 0)
{
total += read; if (total > MaxBytes) throw new ServiceException("FileTooLarge", 413, "Single file limit is 50 MiB.");
await output.WriteAsync(buffer.AsMemory(0, read), ct); hash.AppendData(buffer, 0, read);
}
var info = new ArtifactInfo(id, principalId, Path.GetFileName(file.FileName), file.ContentType ?? "application/octet-stream", total,
Convert.ToHexString(hash.GetHashAndReset()), DateTimeOffset.UtcNow.AddHours(24));
await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(info, ServiceHost.Json), ct);
return info;
}
catch { TryDelete(path); TryDelete(metadataPath); throw; }
}
public ArtifactInfo Get(string principalId, string id)
{
if (!IsId(id)) throw new ServiceException("NotFound", 404, "Artifact not found.");
var info = Read(id);
if (info.PrincipalId != principalId) throw new ServiceException("NotFound", 404, "Artifact not found.");
if (info.ExpiresAt <= DateTimeOffset.UtcNow) { Delete(id); throw new ServiceException("Expired", 410, "Artifact expired."); }
return info;
}
public FileStream Open(string principalId, string id) { Get(principalId, id); return new FileStream(Path.Combine(root, id + ".bin"), FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); }
public void Delete(string id) { if (IsId(id)) { TryDelete(Path.Combine(root, id + ".bin")); TryDelete(Path.Combine(root, id + ".json")); } }
private ArtifactInfo Read(string id) { try { return JsonSerializer.Deserialize<ArtifactInfo>(File.ReadAllText(Path.Combine(root, id + ".json")), ServiceHost.Json) ?? throw new InvalidDataException(); } catch { throw new ServiceException("NotFound", 404, "Artifact not found."); } }
private static bool IsId(string id) => id.Length == 32 && id.All(Uri.IsHexDigit);
private static void TryDelete(string path) { try { File.Delete(path); } catch { } }
}
@@ -0,0 +1,7 @@
namespace WxAgent.Service;
public sealed record DatabaseMessageInfo(long LocalId, long ServerId, string ChatId, string? SenderId, string? SenderName,
long Type, string? Content, DateTimeOffset Timestamp, bool? IsSelf);
public sealed record MergedMessageInfo(string DatabaseRelativePath, DatabaseMessageInfo Parent, string? Title,
string? Description, IReadOnlyList<MergedMessagePart> Messages);
public sealed record MergedMessagePart(string? Sender, string? Content, DateTimeOffset? At, string? Path, int DataType);
@@ -0,0 +1,4 @@
namespace WxAgent.Service;
public sealed record ContactInfo(string Id, string? DisplayName, string? Remark, string? AvatarUrl);
public sealed record GroupMemberInfo(long MemberId, string Id, string DisplayName, bool IsOwner);
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace WxAgent.Service;
public sealed record AgentEvent(string EventId, string AccountId, string? Session, string Kind, string Summary, DateTimeOffset At);
public sealed class EventHub
{
private readonly ConcurrentDictionary<string, Subscription> subscriptions = new();
private readonly object gate = new();
private readonly Queue<AgentEvent> history = new();
private const int MaxHistory = 200;
public (string SubscriptionId, IReadOnlyList<AgentEvent> Replay, bool Gap) Subscribe(string principal, string? after)
{
lock (gate)
{
var replay = after is null ? [] : history.SkipWhile(e => e.EventId != after).Skip(1).ToArray();
var gap = after is not null && !history.Any(e => e.EventId == after);
var id = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16));
subscriptions[id] = new Subscription(principal);
return (id, replay, gap);
}
}
public void Publish(string principal, AgentEvent value)
{
lock (gate)
{
history.Enqueue(value);
while (history.Count > MaxHistory) history.Dequeue();
foreach (var subscription in subscriptions.Values.Where(s => s.Principal == principal))
{
if (subscription.Channel.Writer.TryWrite(value)) continue;
// ponytail: one bounded channel per subscriber; if it overflows, emit an explicit resync marker.
subscription.Channel.Reader.TryRead(out _);
subscription.Channel.Writer.TryWrite(new AgentEvent(
Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)),
value.AccountId, value.Session, "gap", "resync required", DateTimeOffset.UtcNow));
}
}
}
public bool TryGet(string id, string principal, out Subscription subscription) =>
subscriptions.TryGetValue(id, out subscription!) && subscription.Principal == principal;
public void Remove(string id) => subscriptions.TryRemove(id, out _);
public sealed class Subscription(string principal)
{
public string Principal { get; } = principal;
public Channel<AgentEvent> Channel { get; } = System.Threading.Channels.Channel.CreateBounded<AgentEvent>(new BoundedChannelOptions(100)
{ FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false });
}
}
+30
View File
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Hosting;
namespace WxAgent.Service;
public interface IAgentEventSource
{
IAsyncEnumerable<AgentEvent> ListenAsync(CancellationToken cancellationToken);
}
public sealed class EventPump(IAgentBackend backend, EventHub hub, ServiceOptions options) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (backend is not IAgentEventSource source || backend.Capabilities.All(c => c.Operation != "listener-events" || !c.Enabled)) return;
while (!stoppingToken.IsCancellationRequested)
{
try
{
await foreach (var item in source.ListenAsync(stoppingToken))
{
foreach (var credential in options.ReadCredentials())
if (credential.AccountIds.Length == 0 || credential.AccountIds.Contains(item.AccountId, StringComparer.Ordinal))
hub.Publish(credential.PrincipalId, item);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
catch (Exception) { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
}
}
}
@@ -0,0 +1,140 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
namespace WxAgent.Service;
public sealed class OperationQueue(OperationStore store, ServiceSecurity security) : BackgroundService
{
private const int QueueCapacity = 100;
private sealed record Work(OperationRecord Record, ServiceIdentity Identity, string Permission,
Func<CancellationToken, Task> Action, CancellationTokenSource Cancel);
private readonly Channel<Work> queue = Channel.CreateBounded<Work>(new BoundedChannelOptions(QueueCapacity)
{ SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
private readonly ConcurrentDictionary<string, CancellationTokenSource> cancellations = new();
private readonly object admissionGate = new();
private bool stopping;
public OperationRecord Submit(ServiceIdentity identity, string accountId, AgentCapability capability,
string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action)
{
security.RequireCurrent(identity, capability.Permission, accountId);
if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability unavailable.");
if (capability.TimeoutSeconds is < 1 or > 300) throw new ServiceException("InvalidRequest", 400, "Invalid execution budget.");
if (idempotencyKey is { Length: < 1 or > 128 } || (capability.HasSideEffects && string.IsNullOrWhiteSpace(idempotencyKey)))
throw new ServiceException("InvalidRequest", 400, "Side effects require a nonempty idempotency key of at most 128 characters.");
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonicalParameters)));
lock (admissionGate)
{
if (stopping) throw new ServiceException("Unavailable", 503, "Agent is stopping.");
var (record, created) = store.Enqueue(identity.PrincipalId, accountId, capability.Operation,
idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), QueueCapacity);
if (!created) return record;
var cancel = new CancellationTokenSource();
cancellations[record.Id] = cancel;
if (!queue.Writer.TryWrite(new Work(record, identity, capability.Permission, action, cancel)))
{
cancellations.TryRemove(record.Id, out _);
cancel.Dispose();
store.Transition(record.Id, "Failed", "admission", "QueueFull");
throw new ServiceException("QueueFull", 429, "Agent queue is full.");
}
return record;
}
}
public Page<OperationSummary> List(ServiceIdentity identity, string? accountId, int limit, int offset)
{
security.RequireCurrent(identity, "read");
var page = store.List(identity.PrincipalId, accountId, limit, offset);
return new Page<OperationSummary>(page.Items.Select(OperationSummary.From).ToArray(), page.Limit,
page.Offset, page.HasMore, page.NextOffset);
}
public OperationRecord Get(ServiceIdentity identity, string id)
{
security.RequireCurrent(identity, "read");
return store.Get(id, identity.PrincipalId);
}
public OperationRecord Cancel(ServiceIdentity identity, string id)
{
lock (admissionGate)
{
var record = Get(identity, id);
if (cancellations.TryGetValue(id, out var cancel)) cancel.Cancel();
if (record.State == "Queued") store.Transition(id, "Cancelled", "queued", "Cancelled");
}
return Get(identity, id);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await foreach (var work in queue.Reader.ReadAllAsync(stoppingToken))
{
var started = false;
try
{
if (store.Get(work.Record.Id, work.Identity.PrincipalId).State != "Queued") continue;
var remaining = work.Record.ExpiresAt - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) throw new ServiceException("Timeout", 408, "Queue budget expired.");
using var budget = CancellationTokenSource.CreateLinkedTokenSource(work.Cancel.Token, stoppingToken);
budget.CancelAfter(remaining);
lock (admissionGate)
{
budget.Token.ThrowIfCancellationRequested();
security.RequireCurrent(work.Identity, work.Permission, work.Record.AccountId);
store.Transition(work.Record.Id, "Running", "execution");
started = true;
}
// Never release this slot with WaitAsync: the actual action must have stopped first.
await work.Action(budget.Token);
budget.Token.ThrowIfCancellationRequested();
security.RequireCurrent(work.Identity, work.Permission, work.Record.AccountId);
store.Transition(work.Record.Id, "Succeeded", "complete");
}
catch (Exception e)
{
var unknown = started && work.Record.HasSideEffects;
var cancelled = e is OperationCanceledException;
var error = e is ServiceException se ? se.Code : cancelled ? "Cancelled" : "ExecutionFailed";
store.Transition(work.Record.Id, unknown ? "Unconfirmed" : cancelled ? "Cancelled" : "Failed",
started ? "execution" : "admission", error);
}
finally
{
lock (admissionGate)
{
cancellations.TryRemove(work.Record.Id, out _);
work.Cancel.Dispose();
}
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
finally
{
lock (admissionGate)
{
stopping = true;
queue.Writer.TryComplete();
while (queue.Reader.TryRead(out var queued))
{
store.Transition(queued.Record.Id, "Cancelled", "shutdown", "AgentStopping");
cancellations.TryRemove(queued.Record.Id, out _);
queued.Cancel.Dispose();
}
}
}
}
public override Task StopAsync(CancellationToken cancellationToken)
{
lock (admissionGate) { stopping = true; queue.Writer.TryComplete(); }
return base.StopAsync(cancellationToken);
}
}
@@ -0,0 +1,152 @@
using Microsoft.Data.Sqlite;
namespace WxAgent.Service;
public sealed record OperationRecord(string Id, string PrincipalId, string AccountId, string Capability,
string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
string? ErrorCode, bool HasSideEffects);
public sealed record OperationSummary(string Id, string AccountId, string Capability,
string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
string? ErrorCode, bool HasSideEffects)
{
public static OperationSummary From(OperationRecord operation) => new(operation.Id, operation.AccountId,
operation.Capability, operation.State, operation.Stage, operation.CorrelationId, operation.CreatedAt,
operation.ExpiresAt, operation.ErrorCode, operation.HasSideEffects);
}
public sealed class OperationStore : IDisposable
{
private readonly SqliteConnection database;
private readonly object gate = new();
public OperationStore(ServiceOptions options)
{
Directory.CreateDirectory(options.DataDirectory);
database = new SqliteConnection(new SqliteConnectionStringBuilder
{ DataSource = Path.Combine(options.DataDirectory, "operations.sqlite"), Mode = SqliteOpenMode.ReadWriteCreate }.ToString());
database.Open();
Execute("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS operations (
id TEXT PRIMARY KEY, principal TEXT NOT NULL, account TEXT NOT NULL, capability TEXT NOT NULL,
state TEXT NOT NULL, stage TEXT NOT NULL, correlation TEXT NOT NULL, created TEXT NOT NULL,
expires TEXT NOT NULL, error TEXT, side_effects INTEGER NOT NULL, idempotency TEXT, digest TEXT NOT NULL,
UNIQUE(principal, account, capability, idempotency));
UPDATE operations SET state=CASE WHEN side_effects=1 THEN 'Unconfirmed' ELSE 'Failed' END,
stage='restart', error='AgentRestarted' WHERE state='Running';
UPDATE operations SET state='Cancelled', stage='restart', error='AgentRestarted' WHERE state='Queued';
""");
}
public (OperationRecord Record, bool Created) Enqueue(string principal, string account, string capability,
string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity)
{
lock (gate)
{
using var transaction = database.BeginTransaction();
using var lookup = database.CreateCommand();
lookup.Transaction = transaction;
lookup.CommandText = "SELECT *, digest FROM operations WHERE principal=$p AND account=$a AND capability=$c AND idempotency=$k";
lookup.Parameters.AddWithValue("$p", principal);
lookup.Parameters.AddWithValue("$a", account);
lookup.Parameters.AddWithValue("$c", capability);
lookup.Parameters.AddWithValue("$k", (object?)idempotency ?? DBNull.Value);
using (var reader = lookup.ExecuteReader())
{
if (reader.Read())
{
if (reader.GetString(12) != digest) throw new ServiceException("IdempotencyConflict", 409, "Key already used for different parameters.");
return (Read(reader), false);
}
}
using var count = database.CreateCommand();
count.Transaction = transaction;
count.CommandText = "SELECT COUNT(*) FROM operations WHERE state IN ('Queued','Running')";
if (Convert.ToInt64(count.ExecuteScalar()) >= capacity)
throw new ServiceException("QueueFull", 429, "Agent queue is full.");
var now = DateTimeOffset.UtcNow;
var operation = new OperationRecord(Guid.NewGuid().ToString("N"), principal, account, capability,
"Queued", "queued", Guid.NewGuid().ToString("N"), now, now.Add(budget), null, sideEffects);
using var insert = database.CreateCommand();
insert.Transaction = transaction;
insert.CommandText = "INSERT INTO operations VALUES ($id,$p,$a,$c,'Queued','queued',$correlation,$created,$expires,NULL,$effects,$key,$digest)";
insert.Parameters.AddWithValue("$id", operation.Id);
insert.Parameters.AddWithValue("$p", principal);
insert.Parameters.AddWithValue("$a", account);
insert.Parameters.AddWithValue("$c", capability);
insert.Parameters.AddWithValue("$correlation", operation.CorrelationId);
insert.Parameters.AddWithValue("$created", now.ToString("O"));
insert.Parameters.AddWithValue("$expires", operation.ExpiresAt.ToString("O"));
insert.Parameters.AddWithValue("$effects", sideEffects ? 1 : 0);
insert.Parameters.AddWithValue("$key", (object?)idempotency ?? DBNull.Value);
insert.Parameters.AddWithValue("$digest", digest);
insert.ExecuteNonQuery();
transaction.Commit();
return (operation, true);
}
}
public OperationRecord Get(string id, string principal)
{
lock (gate)
{
using var command = database.CreateCommand();
command.CommandText = "SELECT * FROM operations WHERE id=$id AND principal=$principal";
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$principal", principal);
using var reader = command.ExecuteReader();
if (!reader.Read()) throw new ServiceException("NotFound", 404, "Operation not found.");
return Read(reader);
}
}
public Page<OperationRecord> List(string principal, string? accountId, int limit, int offset)
{
lock (gate)
{
using var command = database.CreateCommand();
command.CommandText = string.IsNullOrWhiteSpace(accountId)
? "SELECT * FROM operations WHERE principal=$principal ORDER BY created DESC, id DESC LIMIT $limit OFFSET $offset"
: "SELECT * FROM operations WHERE principal=$principal AND account=$account ORDER BY created DESC, id DESC LIMIT $limit OFFSET $offset";
command.Parameters.AddWithValue("$principal", principal);
if (!string.IsNullOrWhiteSpace(accountId)) command.Parameters.AddWithValue("$account", accountId);
command.Parameters.AddWithValue("$limit", limit + 1);
command.Parameters.AddWithValue("$offset", offset);
using var reader = command.ExecuteReader();
var items = new List<OperationRecord>();
while (reader.Read()) items.Add(Read(reader));
var hasMore = items.Count > limit;
if (hasMore) items.RemoveAt(items.Count - 1);
return new Page<OperationRecord>(items, limit, offset, hasMore, hasMore ? offset + limit : null);
}
}
public void Transition(string id, string state, string stage, string? error = null)
{
lock (gate)
{
using var command = database.CreateCommand();
command.CommandText = "UPDATE operations SET state=$state,stage=$stage,error=$error WHERE id=$id AND state IN ('Queued','Running')";
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$state", state);
command.Parameters.AddWithValue("$stage", stage);
command.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value);
command.ExecuteNonQuery();
}
}
private static OperationRecord Read(SqliteDataReader reader) => new(reader.GetString(0), reader.GetString(1), reader.GetString(2),
reader.GetString(3), reader.GetString(4), reader.GetString(5), reader.GetString(6),
DateTimeOffset.Parse(reader.GetString(7), System.Globalization.CultureInfo.InvariantCulture),
DateTimeOffset.Parse(reader.GetString(8), System.Globalization.CultureInfo.InvariantCulture), reader.IsDBNull(9) ? null : reader.GetString(9), reader.GetInt64(10) != 0);
private void Execute(string sql)
{
using var command = database.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
public void Dispose() => database.Dispose();
}
@@ -0,0 +1,27 @@
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, 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);
public sealed record OperationSubmitRequest(string? Kind, string? AccountId, string? TargetId, string? Text, string? IdempotencyKey, bool Confirmed);
public sealed class ReadOnlyRequest
{
public static (int Limit, int Offset) Page(int limit, int offset)
{
if (limit is < 1 or > 200 || offset < 0) throw new ServiceException("InvalidPagination", 400, "limit must be 1..200 and offset must be non-negative.");
return (limit, offset);
}
}
public static class PageExtensions
{
public static Page<T> ToPage<T>(this IReadOnlyList<T> values, int limit, int offset)
{
var items = values.Skip(offset).Take(limit).ToArray();
var hasMore = offset + items.Length < values.Count;
return new Page<T>(items, limit, offset, hasMore, hasMore ? offset + items.Length : null);
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Text;
using Microsoft.Extensions.Logging;
namespace WxAgent.Service;
public static class RuntimeLog
{
private static readonly object Gate = new();
public static void Append(string path, LogLevel level, string category, string message, Exception? exception = null)
{
try
{
var fullPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var line = Format(level, category, message, exception);
lock (Gate)
{
using var stream = new FileStream(fullPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
writer.WriteLine(line);
}
}
catch
{
// Logging must not prevent the agent from starting or serving requests.
}
}
internal static string Format(LogLevel level, string category, string message, Exception? exception)
{
var line = $"{DateTimeOffset.Now:O} [{level}] {category}: {message}";
return exception is null ? line : $"{line}{Environment.NewLine}{exception}";
}
}
internal sealed class FileLoggerProvider(string path) : ILoggerProvider
{
private readonly string path = Path.GetFullPath(path);
public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName);
public void Dispose() { }
private sealed class FileLogger(FileLoggerProvider provider, string categoryName) : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NoopScope.Instance;
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
RuntimeLog.Append(provider.path, logLevel, categoryName, formatter(state, exception), exception);
}
private sealed class NoopScope : IDisposable
{
public static readonly NoopScope Instance = new();
public void Dispose() { }
}
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace WxAgent.Service;
public static class ServiceHost
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
public static readonly JsonSerializerOptions ConfigurationJson = new(Json)
{ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip };
public static WebApplication Build(ServiceOptions options, IAgentBackend backend,
Action<WebApplicationBuilder>? configure = null, string? logPath = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{ Args = [], WebRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot") });
var runtimeLogPath = logPath ?? Path.Combine(AppContext.BaseDirectory, "wxagent.log");
var fileLogger = new FileLoggerProvider(runtimeLogPath);
builder.Logging.ClearProviders();
builder.Logging.AddProvider(fileLogger);
try
{
options.Validate();
}
catch (Exception exception)
{
RuntimeLog.Append(runtimeLogPath, LogLevel.Error, "WxAgent.Service", "Service configuration validation failed.", exception);
fileLogger.Dispose();
throw;
}
RuntimeLog.Append(runtimeLogPath, LogLevel.Information, "WxAgent.Service", $"Service configured for {options.ListenUrl}.");
builder.WebHost.UseUrls(options.ListenUrl);
builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 1024 * 1024);
// Keep operational logs beside the executable; credentials, headers and request bodies are not logged.
builder.Logging.SetMinimumLevel(LogLevel.Information);
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.None);
builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.None);
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(backend);
builder.Services.AddSingleton<ServiceSecurity>();
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>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AgentService>();
builder.Services.AddMcpServer().WithHttpTransport(o => o.Stateless = true).WithTools<AgentTools>();
configure?.Invoke(builder);
builder.Services.AddRouting();
var app = builder.Build();
app.Use(async (context, next) =>
{
var correlationId = Guid.NewGuid().ToString("N");
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("WxAgent.Http");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
context.Response.Headers["X-Correlation-Id"] = correlationId;
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
try
{
var security = context.RequestServices.GetRequiredService<ServiceSecurity>();
security.ValidateSource(context);
// There are no tokens in query strings, including MCP initialization URLs.
if (context.Request.Query.Keys.Any(k => k.Contains("token", StringComparison.OrdinalIgnoreCase)))
throw new ServiceException("InvalidRequest", 400, "Credentials must not be supplied in URLs.");
var path = context.Request.Path.Value ?? "/";
var isStatic = !path.StartsWith("/api/", StringComparison.Ordinal) && path != "/mcp";
if (!(path == "/api/v1/login" && HttpMethods.IsPost(context.Request.Method)) && !isStatic)
context.Items[typeof(ServiceIdentity)] = security.AuthenticateRequest(context);
await next(context);
}
catch (Exception e) when (!context.Response.HasStarted)
{
var (code, status, message) = e switch
{
ServiceException se => (se.Code, se.StatusCode, se.Message),
BadHttpRequestException or JsonException => ("InvalidRequest", 400, "Invalid request."),
OperationCanceledException => ("Cancelled", 408, "Request cancelled or timed out."),
_ => ("InternalError", 500, "Request failed; use the correlation ID for diagnosis.")
};
logger.LogError(e, "HTTP {Method} {Path} failed with {StatusCode} {ErrorCode}; correlationId={CorrelationId}",
context.Request.Method, context.Request.Path, status, code, correlationId);
context.Response.StatusCode = status;
await context.Response.WriteAsJsonAsync(new { correlationId, error = new { code, message, stage = "request", retry = false } });
}
finally
{
logger.LogInformation("HTTP {Method} {Path} -> {StatusCode}; correlationId={CorrelationId}; elapsedMs={ElapsedMs}",
context.Request.Method, context.Request.Path, context.Response.StatusCode, correlationId, stopwatch.ElapsedMilliseconds);
}
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapPost("/api/v1/login", (HttpContext context, LoginRequest request, ServiceSecurity security) => security.Login(context, request.Token ?? ""));
app.MapPost("/api/v1/logout", (HttpContext context, ServiceSecurity security) => { security.Logout(context); return Results.NoContent(); });
app.MapGet("/api/v1/status", (AgentService service, CancellationToken ct) => service.StatusAsync(ct));
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/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));
app.MapGet("/api/v1/db/merged", (string accountId, string chatId, long localId, AgentService service, CancellationToken ct) => service.DatabaseMergedAsync(accountId, chatId, localId, ct));
app.MapGet("/api/v1/events", StreamEvents);
app.MapPost("/api/v1/files", async (HttpContext context, AgentService service, CancellationToken ct) =>
{
if (!context.Request.HasFormContentType) throw new ServiceException("InvalidRequest", 400, "multipart/form-data is required.");
var form = await context.Request.ReadFormAsync(ct);
if (form.Files.Count != 1) throw new ServiceException("InvalidRequest", 400, "Exactly one file is required.");
return Results.Ok(await service.UploadAsync(form.Files[0], ct));
});
app.MapGet("/api/v1/files/{id}", (string id, AgentService service) => Results.File(service.Download(id), "application/octet-stream"));
app.MapPost("/api/v1/operations", (OperationSubmitRequest request, AgentService service, CancellationToken ct) => service.SubmitOperation(request, ct));
app.MapGet("/api/v1/operations", (string? accountId, int? limit, int? offset, AgentService service) => service.Operations(accountId, limit ?? 50, offset ?? 0));
app.MapGet("/api/v1/operations/{id}", (string id, AgentService service) => service.Operation(id));
app.MapPost("/api/v1/operations/{id}/cancel", (string id, AgentService service) => service.CancelOperation(id));
app.MapMcp("/mcp");
return app;
}
private static async Task StreamEvents(HttpContext context, EventHub hub, ServiceSecurity security, AgentService service)
{
var identity = context.Items[typeof(ServiceIdentity)] as ServiceIdentity
?? throw new ServiceException("Unauthorized", 401, "Authentication required.");
service.RequireEvents();
var after = context.Request.Headers["Last-Event-ID"].ToString();
var (id, replay, gap) = hub.Subscribe(identity.PrincipalId, string.IsNullOrWhiteSpace(after) ? null : after);
context.Response.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-store";
await context.Response.StartAsync(context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
if (!hub.TryGet(id, identity.PrincipalId, out var subscription))
throw new ServiceException("SubscriptionUnavailable", 503, "Could not create event subscription.");
try
{
if (gap) await WriteEvent(context, "gap", new { resyncRequired = true });
foreach (var item in replay) await WriteEvent(context, "message", item);
if (replay.Count != 0) await context.Response.Body.FlushAsync(context.RequestAborted);
while (!context.RequestAborted.IsCancellationRequested)
{
using var wake = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
wake.CancelAfter(TimeSpan.FromSeconds(15));
bool available;
try { available = await subscription.Channel.Reader.WaitToReadAsync(wake.Token); }
catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested)
{ service.RequireEvents(); continue; }
if (!available) break;
security.RequireCurrent(identity, "read");
while (subscription.Channel.Reader.TryRead(out var item))
await WriteEvent(context, item.Kind == "gap" ? "gap" : "message", item);
await context.Response.Body.FlushAsync(context.RequestAborted);
}
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { }
catch (ServiceException) when (context.Response.HasStarted) { }
finally { hub.Remove(id); }
}
private static async Task WriteEvent(HttpContext context, string name, object value)
{
var id = value is AgentEvent e ? e.EventId : "control";
await context.Response.WriteAsync($"id: {id}\nevent: {name}\ndata: {JsonSerializer.Serialize(value, Json)}\n\n", context.RequestAborted);
}
public sealed record LoginRequest(string Token);
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);
}
@@ -0,0 +1,82 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace WxAgent.Service;
public sealed record ServiceCredential(string PrincipalId, string TokenSha256, string[] Permissions, string[] AccountIds);
public sealed class ServiceOptions
{
private static readonly System.Text.Json.JsonSerializerOptions CredentialJson = new(System.Text.Json.JsonSerializerDefaults.Web);
public string ListenUrl { get; init; } = "http://127.0.0.1:5088";
public bool AllowExternal { get; init; }
public string[] AllowedHosts { get; init; } = ["127.0.0.1:5088", "localhost:5088", "[::1]:5088"];
public string[] AllowedOrigins { get; init; } = ["http://127.0.0.1:5088", "http://localhost:5088", "http://[::1]:5088"];
public string? AccessToken { get; init; }
public required string CredentialFile { get; init; }
public required string DataDirectory { get; init; }
// Kept only so older service.json files can be loaded and rewritten by the tray.
[JsonIgnore]
[Obsolete("Internal compatibility field; the value is ignored.")]
public int QueueCapacity { get; init; } = 100;
[JsonIgnore]
[Obsolete("Internal compatibility field; the value is ignored.")]
public string ListenerSession { get; init; } = "文件传输助手";
[JsonIgnore]
[Obsolete("Internal compatibility field; the value is ignored.")]
public bool EnableListenerEvents { get; init; }
public void Validate()
{
if (!Uri.TryCreate(ListenUrl, UriKind.Absolute, out var uri) || uri.Scheme != "http" ||
uri.AbsolutePath != "/" || uri.Query.Length != 0 || uri.Fragment.Length != 0 || uri.UserInfo.Length != 0 ||
!IPAddress.TryParse(uri.Host.Trim('[', ']'), out var address))
throw new ArgumentException("ListenUrl must be an explicit HTTP IP address and port.");
if (!IPAddress.IsLoopback(address) && !AllowExternal)
throw new ArgumentException("External HTTP requires AllowExternal; use a trusted isolated network.");
if (AllowExternal && (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)))
throw new ArgumentException("External HTTP requires a concrete listen IP; do not use 0.0.0.0 or ::.");
if (AccessToken is not null && !IsValidAccessToken(AccessToken))
throw new ArgumentException("AccessToken must be non-empty and contain no whitespace.");
_ = ReadCredentials();
}
// Reread on every authorization boundary: rotation has no overlap or stale cache.
public ServiceCredential[] ReadCredentials()
{
var credentials = System.Text.Json.JsonSerializer.Deserialize<ServiceCredential[]>(File.ReadAllText(CredentialFile), CredentialJson)
?? throw new InvalidDataException("No credentials configured.");
if (credentials.Length != 1)
throw new InvalidDataException("Exactly one credential must be configured.");
if (credentials.Any(c =>
c is null || string.IsNullOrWhiteSpace(c.PrincipalId) || c.TokenSha256 is null || c.TokenSha256.Length != 64 ||
!c.TokenSha256.All(Uri.IsHexDigit) || c.Permissions is null || c.AccountIds is null ||
c.Permissions.Any(p => p is not ("read" or "content" or "write" or "manage" or "local-admin")) ||
c.AccountIds.Any(string.IsNullOrWhiteSpace)))
throw new InvalidDataException("Invalid credential configuration.");
return credentials;
}
public static bool IsValidAccessToken(string? token) =>
!string.IsNullOrEmpty(token) && token.All(c => !char.IsWhiteSpace(c));
public static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
public static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
public sealed record ServiceIdentity(string PrincipalId, string CredentialHash, string[] Permissions, string[] AccountIds, bool LocalOnly = false)
{
public bool Allows(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
public bool AllowsAccount(string accountId) => LocalOnly || AccountIds.Contains(accountId, StringComparer.Ordinal);
}
public sealed class ServiceException(string code, int statusCode, string message) : Exception(message)
{
public string Code { get; } = code;
public int StatusCode { get; } = statusCode;
}
@@ -0,0 +1,171 @@
using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
namespace WxAgent.Service;
public sealed class ServiceSecurity(ServiceOptions options)
{
private static readonly ServiceIdentity LocalIdentity = new("local", "local", ["read", "content", "manage", "local-admin"], [], true);
private sealed record BrowserSession(ServiceIdentity Identity, string Csrf, DateTimeOffset Expires);
private readonly ConcurrentDictionary<string, BrowserSession> sessions = new();
private readonly object loginGate = new();
private DateTimeOffset loginWindow = DateTimeOffset.UtcNow;
private int loginAttempts;
public const string CookieName = "wxagent-session";
public ServiceIdentity? AuthenticateToken(string token)
{
if (!ServiceOptions.IsValidAccessToken(token)) return null;
var hash = ServiceOptions.HashToken(token);
return ReadCredentialsSafely().Where(c => EqualHash(c.TokenSha256, hash))
.Select(c => new ServiceIdentity(c.PrincipalId, c.TokenSha256, c.Permissions, c.AccountIds)).SingleOrDefault();
}
public ServiceIdentity RequireCurrent(ServiceIdentity original, string? permission = null, string? accountId = null)
{
if (original.LocalOnly)
{
if (permission is not null && !original.Allows(permission))
throw new ServiceException("Forbidden", 403, "Permission required.");
return original;
}
var credential = ReadCredentialsSafely().SingleOrDefault(c => c.PrincipalId == original.PrincipalId &&
EqualHash(c.TokenSha256, original.CredentialHash));
if (credential is null) throw new ServiceException("AuthorizationRevoked", 401, "Credential expired or revoked.");
var current = new ServiceIdentity(credential.PrincipalId, credential.TokenSha256, credential.Permissions, credential.AccountIds);
if (permission is not null && !current.Allows(permission))
throw new ServiceException("Forbidden", 403, "Permission required.");
// AccountIds is retained for credential-file compatibility. Account-level business isolation belongs to callers;
// the service still validates the selected account/window/target at the backend boundary.
return current;
}
private ServiceCredential[] ReadCredentialsSafely()
{
try { return options.ReadCredentials(); }
catch (Exception e) when (e is IOException or InvalidDataException or UnauthorizedAccessException or System.Text.Json.JsonException or ArgumentException)
{ return []; } // Fail closed during invalid or incomplete local rotation.
}
private static bool EqualHash(string a, string b) => CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(a), Convert.FromHexString(b));
public void ValidateSource(HttpContext context)
{
if (!IsAllowedHost(context.Request.Host.Value))
throw new ServiceException("UntrustedHost", 403, "Request host is not trusted.");
var origin = context.Request.Headers.Origin.ToString();
if (!string.IsNullOrWhiteSpace(origin) && !IsAllowedOrigin(origin))
throw new ServiceException("UntrustedOrigin", 403, "Request origin is not trusted.");
}
public static void RequireLocal(HttpContext context)
{
if (context.Connection.RemoteIpAddress is not { } address || !IPAddress.IsLoopback(address))
throw new ServiceException("LocalOnly", 403, "This action requires a direct loopback connection.");
}
public ServiceIdentity AuthenticateRequest(HttpContext context)
{
if (context.Request.Headers.Authorization.Count != 0)
{
var header = context.Request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && AuthenticateToken(header[7..]) is { } identity)
return identity;
throw new ServiceException("Unauthorized", 401, "Valid Bearer credential required.");
}
var isMutation = !HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method);
if (!context.Request.Cookies.TryGetValue(CookieName, out var id) || !sessions.TryGetValue(id, out var session))
{
if (!IsLoopback(context)) throw new ServiceException("Unauthorized", 401, "Login required.");
if (isMutation)
{
RequireTrustedOrigin(context, required: true);
if (context.Request.Headers["X-WxAgent-Local"].ToString() != "1")
throw new ServiceException("LocalRequestHeaderRequired", 403, "Browser writes require the local request header.");
}
return LocalIdentity;
}
if (session.Identity.LocalOnly && !IsLoopback(context))
throw new ServiceException("Unauthorized", 401, "Local session cannot be used remotely.");
if (session.Expires <= DateTimeOffset.UtcNow)
{
sessions.TryRemove(id, out _);
throw new ServiceException("Unauthorized", 401, "Session expired.");
}
var current = RequireCurrent(session.Identity);
if (isMutation)
{
RequireTrustedOrigin(context, required: true);
if (context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf)
throw new ServiceException("CsrfRejected", 403, "CSRF token required.");
}
return current;
}
public object Login(HttpContext context, string token)
{
RequireTrustedOrigin(context, required: true);
// ponytail: process-wide login throttle; per-IP quotas only if legitimate shared use needs them.
lock (loginGate)
{
if (DateTimeOffset.UtcNow - loginWindow > TimeSpan.FromMinutes(1))
{ loginWindow = DateTimeOffset.UtcNow; loginAttempts = 0; }
if (++loginAttempts > 10) throw new ServiceException("RateLimited", 429, "Wait before attempting login again.");
}
var identity = AuthenticateToken(token) ?? throw new ServiceException("Unauthorized", 401, "Invalid credential.");
foreach (var entry in sessions.Where(s => s.Value.Expires <= DateTimeOffset.UtcNow)) sessions.TryRemove(entry.Key, out _);
if (sessions.Count >= 100) throw new ServiceException("RateLimited", 429, "Browser session limit reached.");
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var csrf = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var expires = DateTimeOffset.UtcNow.AddMinutes(30);
sessions[id] = new BrowserSession(identity, csrf, expires);
context.Response.Cookies.Append(CookieName, id, new CookieOptions
{ HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = false, Path = "/", MaxAge = TimeSpan.FromMinutes(30), IsEssential = true });
return new { identity.PrincipalId, identity.Permissions, identity.AccountIds, csrfToken = csrf, expires };
}
private void RequireTrustedOrigin(HttpContext context, bool required)
{
var origin = context.Request.Headers.Origin.ToString();
if (string.IsNullOrWhiteSpace(origin))
{
if (required) throw new ServiceException("OriginRequired", 403, "A trusted Origin is required for browser writes.");
return;
}
if (!IsAllowedOrigin(origin)) throw new ServiceException("UntrustedOrigin", 403, "Request origin is not trusted.");
}
private bool IsAllowedHost(string host)
{
if (string.IsNullOrWhiteSpace(host)) return false;
if (options.AllowedHosts.Any(value => string.Equals(value, host, StringComparison.OrdinalIgnoreCase))) return true;
if (!Uri.TryCreate(options.ListenUrl, UriKind.Absolute, out var listen) ||
!IPAddress.TryParse(listen.Host.Trim('[', ']'), out var address) || IsAnyAddress(address)) return false;
return string.Equals(listen.Authority, host, StringComparison.OrdinalIgnoreCase);
}
private bool IsAllowedOrigin(string origin)
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var parsed) || parsed.Scheme != "http" ||
parsed.UserInfo.Length != 0 || parsed.AbsolutePath != "/" || parsed.Query.Length != 0 || parsed.Fragment.Length != 0)
return false;
var normalized = origin.TrimEnd('/');
if (options.AllowedOrigins.Any(value => string.Equals(value.TrimEnd('/'), normalized, StringComparison.OrdinalIgnoreCase))) return true;
return Uri.TryCreate(options.ListenUrl, UriKind.Absolute, out var listen) &&
string.Equals(listen.GetLeftPart(UriPartial.Authority), normalized, StringComparison.OrdinalIgnoreCase);
}
private static bool IsAnyAddress(IPAddress address) => address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any);
private static bool IsLoopback(HttpContext context) =>
context.Connection.RemoteIpAddress is { } address && IPAddress.IsLoopback(address);
public void Logout(HttpContext context)
{
if (context.Request.Cookies.TryGetValue(CookieName, out var id)) sessions.TryRemove(id, out _);
context.Response.Cookies.Delete(CookieName, new CookieOptions { Path = "/" });
}
}
@@ -0,0 +1,4 @@
namespace WxAgent.Service;
public sealed record SessionSearchInfo(string Name, string AutomationId, bool IsExactMatch);
public sealed record SessionViewportInfo(int ScrolledPages, bool Changed, IReadOnlyList<SessionInfo> Sessions);
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../WxAgent.Core/WxAgent.Core.csproj" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.28" />
</ItemGroup>
<ItemGroup>
<Content Include="wwwroot/**/*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+569
View File
@@ -0,0 +1,569 @@
const $ = (id) => document.querySelector(`#${id}`);
let csrf = "";
let refreshTimer = 0;
let busy = false;
let activeView = localStorage.getItem("wxagent-view") || "messages";
let capabilityMap = new Map();
const operationStates = {
Queued: "排队中",
Running: "执行中",
Succeeded: "已完成",
Failed: "失败",
Cancelled: "已取消",
Unconfirmed: "待核对",
};
function showError(error) {
const code = error?.code ? `${error.code}:` : "";
$("error").textContent = `${code}${error?.message || "请求失败"}`;
}
function clearError() {
$("error").textContent = "";
}
async function api(path, init = {}) {
const response = await fetch(path, {
...init,
credentials: "same-origin",
headers: { Accept: "application/json", ...init.headers },
});
if (!response.ok) {
let body = {};
try {
body = await response.json();
} catch {
/* response may not be JSON */
}
if (response.status === 401) {
$("app").hidden = true;
$("login").hidden = false;
$("connectionBadge").textContent = "未登录";
$("connectionBadge").className = "badge neutral";
}
const error = new Error(body.error?.message || `HTTP ${response.status}`);
error.code = body.error?.code;
error.correlationId =
response.headers.get("X-Correlation-Id") || body.correlationId;
throw error;
}
return response.status === 204 ? null : response.json();
}
async function get(path) {
try {
return { ok: true, value: await api(path) };
} catch (error) {
return { ok: false, error };
}
}
async function mutate(path, body) {
return api(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
"X-WxAgent-Local": "1",
},
body: JSON.stringify(body),
});
}
function setList(id, items, format) {
const target = $(id);
target.replaceChildren(
...items.map((item) => {
const row = document.createElement("div");
row.className = "row";
row.textContent = format(item);
return row;
}),
);
}
function setListMessage(id, message, error = false) {
const target = $(id);
const row = document.createElement("div");
row.className = error ? "row error-row" : "row";
row.textContent = message;
target.replaceChildren(row);
}
function value(result, fallback) {
return result.ok ? result.value : fallback;
}
function friendlyAccount(account) {
const binding = account.binding;
const identity =
binding?.nickname || binding?.wechatId || account.displayName;
return identity
? `${identity} · ${account.bindingStatus || "Unknown"}`
: `数据库账号 ${String(account.accountId).slice(0, 10)}… · ${account.bindingStatus || "Unknown"}`;
}
function renderStatus(result) {
if (!result.ok) {
$("connectionBadge").textContent = "状态读取失败";
$("connectionBadge").className = "badge error";
return;
}
const status = result.value;
$("status").textContent = JSON.stringify(status, null, 2);
$("serviceState").textContent = status.serviceOnline ? "在线" : "离线";
$("wechatState").textContent =
status.wechatAvailable && status.sessionAvailable ? "可用" : "不可用";
$("bindingState").textContent = status.activeAccountBound
? "已绑定"
: "未绑定";
$("modeState").textContent = status.defaultReadOnly ? "只读" : "可写";
const healthy =
status.serviceOnline && status.wechatAvailable && status.sessionAvailable;
$("connectionBadge").textContent = healthy ? "已连接" : "需检查";
$("connectionBadge").className = `badge ${healthy ? "ok" : "warn"}`;
}
function renderCapabilities(result) {
if (!result.ok) {
capabilityMap = new Map();
updateReplyAvailability();
return setListMessage("capabilities", result.error.message, true);
}
capabilityMap = new Map(result.value.map((item) => [item.operation, item]));
setList(
"capabilities",
result.value,
(item) =>
`${item.operation} — ${item.enabled ? "可用" : "禁用"}${item.disabledReason ? `:${item.disabledReason}` : ""}`,
);
updateReplyAvailability();
}
function renderBindings(accounts, targets) {
const accountRows = accounts.map((account) => {
const row = document.createElement("div");
row.className = "row";
const main = document.createElement("div");
main.className = "row-main";
const title = document.createElement("div");
title.className = "row-title";
title.textContent = friendlyAccount(account);
const meta = document.createElement("div");
meta.className = "row-meta";
meta.textContent = account.binding
? `${account.binding.wechatId || "微信号未读取"} · PID ${account.binding.processId} · HWND ${account.binding.windowHandle}`
: `Fingerprint: ${account.accountId}`;
main.append(title, meta);
row.append(main);
if (account.binding) {
const unbind = document.createElement("button");
unbind.className = "secondary";
unbind.textContent = "解绑";
unbind.onclick = async () => {
unbind.disabled = true;
try {
await mutate("/api/v1/accounts/unbind", {
accountId: account.accountId,
});
await refresh();
} catch (error) {
showError(error);
unbind.disabled = false;
}
};
row.append(unbind);
}
return row;
});
$("accounts").replaceChildren(...accountRows);
if (!accountRows.length) setListMessage("accounts", "暂无数据库账号。");
const targetRows = targets.map((target) => {
const row = document.createElement("div");
row.className = "row";
const main = document.createElement("div");
main.className = "row-main";
const title = document.createElement("div");
title.className = "row-title";
title.textContent = target.wechatId || target.nickname || "身份未读取";
const meta = document.createElement("div");
meta.className = "row-meta";
meta.textContent = `${target.targetId} · PID ${target.processId} · HWND ${target.windowHandle}${target.isBound ? " · 已绑定" : ""}`;
main.append(title, meta);
row.append(main);
if (!target.isBound) {
const select = document.createElement("select");
select.setAttribute("aria-label", `为 ${target.targetId} 选择数据库账号`);
select.append(
new Option("选择账号", ""),
...accounts
.filter((account) => !account.binding)
.map(
(account) =>
new Option(friendlyAccount(account), account.accountId),
),
);
const bind = document.createElement("button");
bind.textContent = "绑定";
bind.onclick = async () => {
if (!select.value) {
showError(new Error("请先选择数据库账号"));
return;
}
bind.disabled = true;
try {
await mutate("/api/v1/accounts/bind", {
accountId: select.value,
targetId: target.targetId,
});
await refresh();
} catch (error) {
showError(error);
bind.disabled = false;
}
};
row.append(select, bind);
}
return row;
});
$("uiTargets").replaceChildren(...targetRows);
if (!targetRows.length)
setListMessage("uiTargets", "未发现当前交互式会话中的微信主窗口。");
}
function populateAccounts(result) {
if (!result.ok) {
setListMessage("accounts", result.error.message, true);
return [];
}
const accounts = result.value.items || [];
const select = $("accountSelect");
const previous = select.value;
select.replaceChildren(
new Option("选择已绑定账号", ""),
...accounts
.filter((account) => account.binding)
.map(
(account) => new Option(friendlyAccount(account), account.accountId),
),
);
select.value = accounts.some(
(account) => account.accountId === previous && account.binding,
)
? previous
: "";
return accounts;
}
function renderScoped(result, id, format, emptyMessage) {
if (!result.ok) return setListMessage(id, result.error.message, true);
const items = result.value.items || [];
setList(id, items, format);
if (!items.length) setListMessage(id, emptyMessage);
}
function renderReplyTargets(result) {
const sessions = result.ok ? result.value.items || [] : [];
const select = $("replyTarget");
const previous = select.value;
select.replaceChildren(
new Option("选择会话", ""),
...sessions.map(
(session) => new Option(session.name, session.automationId),
),
);
select.value = sessions.some((session) => session.automationId === previous)
? previous
: "";
updateReplyAvailability();
}
function updateReplyAvailability() {
const capability = capabilityMap.get("send-text");
const hasAccount = Boolean($("accountSelect")?.value);
const hasTarget = Boolean($("replyTarget")?.value);
const enabled = Boolean(capability?.enabled && hasAccount && hasTarget);
$("replyTarget").disabled = !capability?.enabled;
$("replyText").disabled = !capability?.enabled;
$("sendReply").disabled = !enabled;
$("replyCapability").textContent = capability?.enabled
? "发送前会再次校验账号和目标"
: capability?.disabledReason || "写能力尚未开放。";
if (capability?.enabled) {
$("replyState").textContent = hasTarget
? "点击发送后创建任务。"
: "请选择目标会话。";
} else {
$("replyState").textContent = "当前不会发送消息。";
}
}
async function sendReply() {
const accountId = $("accountSelect").value;
const targetId = $("replyTarget").value;
const text = $("replyText").value;
if (!accountId || !targetId || !text.trim()) {
showError(new Error("请选择账号、目标会话并输入文本。"));
return;
}
const button = $("sendReply");
button.disabled = true;
clearError();
try {
await api("/api/v1/operations", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
"X-WxAgent-Local": "1",
},
body: JSON.stringify({
kind: "send-text",
accountId,
targetId,
text,
idempotencyKey: crypto.randomUUID(),
confirmed: true,
}),
});
$("replyText").value = "";
setView("operations");
await refresh();
} catch (error) {
showError(error);
} finally {
updateReplyAvailability();
}
}
function operationState(state) {
return operationStates[state] || state || "未知";
}
function renderOperations(result) {
if (!result.ok)
return setListMessage("operations", result.error.message, true);
const items = result.value.items || [];
const target = $("operations");
target.replaceChildren(
...items.map((item) => {
const row = document.createElement("div");
row.className = "row task-row";
const main = document.createElement("div");
main.className = "row-main";
const title = document.createElement("div");
title.className = "row-title";
title.textContent = `${item.capability} · ${operationState(item.state)}`;
const meta = document.createElement("div");
meta.className = "row-meta";
const created = item.createdAt
? new Date(item.createdAt).toLocaleString()
: "时间未知";
meta.textContent = `${created} · ${item.stage || "未开始"} · 账号 ${String(item.accountId || "").slice(0, 10)}…`;
main.append(title, meta);
const actions = document.createElement("div");
actions.className = "row-actions";
const detail = document.createElement("button");
detail.className = "secondary";
detail.textContent = "详情";
detail.onclick = async () => {
detail.disabled = true;
try {
$("operation").textContent = JSON.stringify(
await api(`/api/v1/operations/${encodeURIComponent(item.id)}`),
null,
2,
);
} catch (error) {
showError(error);
} finally {
detail.disabled = false;
}
};
actions.append(detail);
if (item.state === "Queued" || item.state === "Running") {
const stop = document.createElement("button");
stop.className = "secondary";
stop.textContent = "停止";
stop.onclick = async () => {
stop.disabled = true;
try {
await api(
`/api/v1/operations/${encodeURIComponent(item.id)}/cancel`,
{
method: "POST",
headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" },
},
);
await refresh();
} catch (error) {
showError(error);
stop.disabled = false;
}
};
actions.append(stop);
}
row.append(main, actions);
return row;
}),
);
if (!items.length) setListMessage("operations", "暂无任务。");
}
function setView(view) {
activeView = view;
localStorage.setItem("wxagent-view", view);
document
.querySelectorAll("[data-view]")
.forEach((button) =>
button.classList.toggle("active", button.dataset.view === view),
);
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
panel.hidden = panel.dataset.viewPanel !== view;
});
}
async function refresh() {
if (busy) return;
busy = true;
clearError();
$("refresh").disabled = true;
$("refresh").textContent = "刷新中…";
try {
const [status, capabilities, accounts, targets, operations] =
await Promise.all([
get("/api/v1/status"),
get("/api/v1/capabilities"),
get("/api/v1/accounts?limit=50"),
get("/api/v1/ui-targets"),
get("/api/v1/operations?limit=50"),
]);
renderStatus(status);
renderCapabilities(capabilities);
renderOperations(operations);
const accountItems = populateAccounts(accounts);
renderBindings(accountItems, value(targets, []));
const accountId = $("accountSelect").value;
if (!accountId) {
const message = "在“连接与设置”确认账号后加载数据。";
setListMessage("sessions", message);
setListMessage("contacts", message);
setListMessage("messages", message);
renderReplyTargets({ ok: true, value: { items: [] } });
return;
}
const query = `&accountId=${encodeURIComponent(accountId)}`;
const [sessions, contacts, messages] = await Promise.all([
get(`/api/v1/sessions?limit=50${query}`),
get(`/api/v1/contacts?limit=50${query}`),
get(
`/api/v1/messages?limit=50&includeContent=${$("content").checked}${query}`,
),
]);
renderScoped(
sessions,
"sessions",
(item) => `${item.name}${item.isCurrent ? "(当前)" : ""}`,
"暂无可见会话。",
);
renderReplyTargets(sessions);
renderScoped(
contacts,
"contacts",
(item) => `${item.displayName || "[未知]"} — ${item.id}`,
"暂无联系人数据或当前凭据尚未开放。",
);
renderScoped(
messages,
"messages",
(item) =>
`${item.type} ${item.sender || ""}: ${item.content ?? item.summary ?? "[正文未授权]"}`,
"暂无可见消息。",
);
} finally {
$("refresh").disabled = false;
$("refresh").textContent = "刷新";
$("lastUpdated").textContent = `更新于 ${new Date().toLocaleTimeString()}`;
busy = false;
}
}
function setAutoRefresh(enabled) {
if (refreshTimer) window.clearInterval(refreshTimer);
refreshTimer = enabled
? window.setInterval(() => {
if (!document.hidden) refresh();
}, 15000)
: 0;
localStorage.setItem("wxagent-auto-refresh", enabled ? "1" : "0");
}
$("loginForm").addEventListener("submit", async (event) => {
event.preventDefault();
const button = $("loginButton");
button.disabled = true;
button.textContent = "连接中…";
clearError();
try {
const response = await api("/api/v1/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: $("token").value }),
});
csrf = response.csrfToken;
$("token").value = "";
$("login").hidden = true;
$("app").hidden = false;
await refresh();
} catch (error) {
showError(error);
} finally {
button.disabled = false;
button.textContent = "连接";
}
});
$("refresh").onclick = refresh;
$("accountSelect").onchange = refresh;
$("content").onchange = refresh;
$("replyTarget").onchange = updateReplyAvailability;
$("replyText").oninput = updateReplyAvailability;
$("sendReply").onclick = sendReply;
$("autoRefresh").checked = localStorage.getItem("wxagent-auto-refresh") === "1";
$("autoRefresh").onchange = (event) => setAutoRefresh(event.target.checked);
document.querySelectorAll("[data-view]").forEach((button) => {
button.onclick = () => setView(button.dataset.view);
});
setView(
document.querySelector(`[data-view="${activeView}"]`)
? activeView
: "messages",
);
if ($("autoRefresh").checked) setAutoRefresh(true);
$("logout").onclick = async () => {
try {
await api("/api/v1/logout", {
method: "POST",
headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" },
});
} finally {
if (refreshTimer) window.clearInterval(refreshTimer);
csrf = "";
$("app").hidden = true;
$("login").hidden = false;
$("connectionBadge").textContent = "未连接";
$("connectionBadge").className = "badge neutral";
}
};
(async () => {
const local = await get("/api/v1/status");
if (local.ok) {
$("login").hidden = true;
$("app").hidden = false;
await refresh();
}
})();
@@ -0,0 +1,89 @@
<!doctype html>
<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>
<header class="hero">
<div>
<p class="eyebrow">DESKTOP AGENT</p>
<h1>WxAgent 工作台</h1>
<p class="subtitle">连接 Agent,查看消息、联系人和执行任务。</p>
</div>
<span id="connectionBadge" class="badge neutral">未连接</span>
</header>
<p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
<section id="login" class="panel login-panel">
<h2>连接 Agent</h2>
<p class="muted">本机打开会自动进入;远程访问请输入连接 Token。Token 不会保存到页面或 URL。</p>
<form id="loginForm" class="login-form">
<label for="token">连接 Token</label>
<div class="input-row"><input id="token" type="password" autocomplete="off" required placeholder="粘贴 Token"><button id="loginButton">连接</button></div>
</form>
</section>
<section id="app" class="app-shell" hidden>
<div class="toolbar panel">
<div class="toolbar-group"><button id="refresh">刷新</button><label class="toggle"><input id="autoRefresh" type="checkbox"> 自动刷新 <span class="muted">15 秒</span></label><span id="lastUpdated" class="muted">尚未刷新</span></div>
<div class="toolbar-group account-picker"><label for="accountSelect">当前账号</label><select id="accountSelect"><option value="">选择已绑定账号</option></select></div>
<button id="logout" class="secondary">退出</button>
</div>
<nav class="workbench-nav panel" aria-label="工作台导航">
<button class="nav-button active" data-view="messages">消息</button>
<button class="nav-button" data-view="contacts">联系人</button>
<button class="nav-button" data-view="broadcast">群发</button>
<button class="nav-button" data-view="operations">任务</button>
<button class="nav-button" data-view="settings">连接与设置</button>
</nav>
<p id="error" class="error" role="alert" aria-live="assertive"></p>
<section class="view-panel" data-view-panel="messages">
<section class="panel intro-panel">
<div><p class="eyebrow">消息</p><h2>从已确认账号开始</h2><p class="muted">先在“连接与设置”确认账号和微信窗口,再加载会话与消息。</p></div>
<label class="toggle"><input id="content" type="checkbox"> 显示正文 <span class="muted">需要 content 权限</span></label>
</section>
<section class="panel"><div class="section-heading"><h2>会话</h2><span class="muted">当前账号</span></div><div id="sessions" class="list"></div></section>
<section class="panel reply-panel"><div class="section-heading"><h2>回复</h2><span id="replyCapability" class="muted">发送能力检查中…</span></div><div class="reply-form"><label for="replyTarget">目标会话</label><select id="replyTarget"><option value="">先选择会话</option></select><label for="replyText">文本</label><textarea id="replyText" rows="4" maxlength="4000" placeholder="输入要发送的文本" disabled></textarea><div class="reply-actions"><button id="sendReply" disabled>发送</button><span id="replyState" class="muted">写能力尚未开放。</span></div></div></section>
<section class="panel"><div class="section-heading"><h2>可见消息</h2><span class="muted">只显示当前可读取的数据</span></div><div id="messages" class="list"></div></section>
</section>
<section class="view-panel" data-view-panel="contacts" hidden>
<section class="panel intro-panel"><div><p class="eyebrow">联系人</p><h2>联系人和群</h2><p class="muted">当前阶段只读;群发会在目标确认和任务能力完成后开放。</p></div></section>
<section class="panel"><div class="section-heading"><h2>联系人 / 群</h2><span class="muted">稳定 ID 去重</span></div><div id="contacts" class="list"></div></section>
</section>
<section class="view-panel" data-view-panel="broadcast" hidden>
<section class="panel placeholder"><p class="eyebrow">群发</p><h2>逐项确认后发送</h2><p class="muted">群发任务尚未开放。开放后会显示目标清单、幂等键、逐项结果和停止任务入口;当前不会发送消息。</p><button disabled>暂未开放</button></section>
</section>
<section class="view-panel" data-view-panel="operations" hidden>
<section class="panel"><div class="section-heading"><h2>任务中心</h2><span class="muted">当前登录身份的任务</span></div><div id="operations" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>技术详情</h2><span class="muted">用于反馈和诊断</span></div><pre id="operation"></pre></section>
</section>
<section class="view-panel" data-view-panel="settings" hidden>
<div class="status-grid" aria-live="polite">
<div class="metric panel"><span>服务</span><strong id="serviceState">—</strong></div>
<div class="metric panel"><span>微信 UI</span><strong id="wechatState">—</strong></div>
<div class="metric panel"><span>当前绑定</span><strong id="bindingState">—</strong></div>
<div class="metric panel"><span>默认模式</span><strong id="modeState">只读</strong></div>
</div>
<details class="panel details"><summary>诊断详情</summary><pre id="status" role="status"></pre></details>
<section class="panel"><div class="section-heading"><h2>能力</h2><span class="muted">按当前凭据显示</span></div><div id="capabilities" class="list"></div></section>
<div class="dashboard-grid">
<section class="panel"><div class="section-heading"><h2>账号绑定</h2><span class="muted">数据库账号 ↔ 微信窗口</span></div><div id="accounts" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>微信窗口</h2><span class="muted">自动匹配;失败时手动绑定</span></div><div id="uiTargets" class="list"></div></section>
</div>
</section>
</section>
</main>
<script type="module" src="app.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long