feat: validate single-client broadcast operations
Build web service image / build (push) Successful in 1m9s

This commit is contained in:
2026-09-19 14:33:26 +08:00
parent e321d38fa3
commit c7c0ab273f
62 changed files with 2861 additions and 12651 deletions
+123 -8
View File
@@ -133,19 +133,134 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security
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)
public async Task<OperationRecord> SubmitOperationAsync(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 kind = RequireText(request.Kind, "kind", 80);
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));
if (string.Equals(kind, "send-text", StringComparison.Ordinal))
{
var targetId = RequireText(request.TargetId, "targetId", 512);
var capability = RequireCapability("send-text");
var canonical = System.Text.Json.JsonSerializer.Serialize(new { kind, accountId, targetId, text });
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
}
if (!string.Equals(kind, "broadcast-text", StringComparison.Ordinal))
throw new ServiceException("UnsupportedOperation", 400, "Only send-text and broadcast-text are available in this phase.");
if (!string.IsNullOrWhiteSpace(request.TargetId))
throw new ServiceException("InvalidRequest", 400, "broadcast-text uses the targets list, not targetId.");
var capabilityForBroadcast = RequireCapability("broadcast-text");
var targets = await FreezeBroadcastTargetsAsync(accountId, request.Targets, ct).ConfigureAwait(false);
var initialDetails = System.Text.Json.JsonSerializer.Serialize(new
{
targetIds = targets,
stopOnError = request.StopOnError
}, ServiceHost.Json);
var canonicalBroadcast = System.Text.Json.JsonSerializer.Serialize(new
{
kind,
accountId,
targetIds = targets,
text,
stopOnError = request.StopOnError
});
return operations.SubmitResult(Identity, accountId, capabilityForBroadcast, idempotencyKey, canonicalBroadcast,
initialDetails,
cancellation => ExecuteBroadcastAsync(accountId, targets, text, request.StopOnError, cancellation));
}
private async Task<IReadOnlyList<string>> FreezeBroadcastTargetsAsync(string accountId, IReadOnlyList<string>? requested, CancellationToken ct)
{
if (requested is null || requested.Count is < 1 or > 20)
throw new ServiceException("InvalidRequest", 400, "broadcast-text requires 1..20 targets.");
var targets = requested
.Select(target => RequireText(target, "target", 512))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (targets.Length == 0) throw new ServiceException("InvalidRequest", 400, "broadcast-text requires at least one unique target.");
// Freeze the current UI target list before enqueueing. The account binding and identity are
// still revalidated by every side-effecting send, so this preflight cannot bypass account safety.
var visible = await backend.SessionsAsync(ct).ConfigureAwait(false);
var visibleIds = visible.Select(session => session.AutomationId).ToHashSet(StringComparer.Ordinal);
var missing = targets.Where(target => !visibleIds.Contains(target)).ToArray();
if (missing.Length > 0)
throw new ServiceException("TargetNotFound", 409, "Every broadcast target must be a visible, uniquely bound session.");
return targets;
}
private async Task<OperationExecutionResult> ExecuteBroadcastAsync(string accountId, IReadOnlyList<string> targets,
string text, bool stopOnError, CancellationToken ct)
{
var items = new List<BroadcastItemResult>(targets.Count);
var stopped = false;
string? stopReason = null;
string? errorCode = null;
var unconfirmed = false;
foreach (var target in targets)
{
try
{
await backend.SendTextAsync(accountId, target, text, ct).ConfigureAwait(false);
items.Add(new BroadcastItemResult(target, "Succeeded", null));
}
catch (OperationCanceledException)
{
items.Add(new BroadcastItemResult(target, "Unconfirmed", "Cancelled"));
stopped = true;
stopReason = "Cancelled";
errorCode = "Cancelled";
unconfirmed = true;
break;
}
catch (ServiceException exception)
{
var itemState = exception.Code == "ResultUnconfirmed" ? "Unconfirmed" : "Failed";
items.Add(new BroadcastItemResult(target, itemState, exception.Code));
errorCode ??= exception.Code == "ResultUnconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
if (itemState == "Unconfirmed" || stopOnError)
{
stopped = true;
stopReason = exception.Code;
unconfirmed = itemState == "Unconfirmed";
break;
}
}
catch (WxAgentException exception)
{
var code = exception.Code.ToString();
var itemState = exception.Code == WxAgentErrorCode.ResultUnconfirmed ? "Unconfirmed" : "Failed";
items.Add(new BroadcastItemResult(target, itemState, code));
errorCode ??= itemState == "Unconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
if (itemState == "Unconfirmed" || stopOnError)
{
stopped = true;
stopReason = code;
unconfirmed = itemState == "Unconfirmed";
break;
}
}
catch (Exception)
{
items.Add(new BroadcastItemResult(target, "Unconfirmed", "ExecutionFailed"));
stopped = true;
stopReason = "ExecutionFailed";
errorCode = "ExecutionFailed";
unconfirmed = true;
break;
}
}
var details = System.Text.Json.JsonSerializer.Serialize(new BroadcastResult(targets, items, stopped, stopReason), ServiceHost.Json);
return new OperationExecutionResult(details, errorCode, unconfirmed);
}
private static string RequireText(string? value, string name, int maxLength)