feat: validate single-client broadcast operations
Build web service image / build (push) Successful in 1m9s
Build web service image / build (push) Successful in 1m9s
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user