refactor: split node agent and Go control plane
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user