2849 lines
110 KiB
C#
2849 lines
110 KiB
C#
using System.Globalization;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Nexus.Api.Models;
|
|
|
|
namespace Nexus.Api.Services;
|
|
|
|
/// <summary>
|
|
/// Stable, browser-safe facade over OpenClaw's protocol-v4 control plane.
|
|
/// Gateway payloads are intentionally normalized here so frontend components
|
|
/// never depend on OpenClaw's internal storage or raw protocol shapes.
|
|
/// </summary>
|
|
public sealed class OpenClawControlService(
|
|
IGatewayConnector connector,
|
|
IConfiguration configuration,
|
|
IOpenClawWriteGate writeGate,
|
|
ILogger<OpenClawControlService> logger,
|
|
IHttpContextAccessor? httpContextAccessor = null,
|
|
IOpenClawOperationAuditStore? operationAuditStore = null,
|
|
IOpenClawManagementState? managementState = null) : IOpenClawControlService
|
|
{
|
|
private static readonly string[] ApprovalDecisions = ["allow-once", "allow-always", "deny"];
|
|
private static readonly HashSet<string> ModelAuthStatuses =
|
|
[
|
|
"ok",
|
|
"expiring",
|
|
"expired",
|
|
"missing",
|
|
"static"
|
|
];
|
|
private static readonly HashSet<string> ModelAuthProfileTypes =
|
|
[
|
|
"oauth",
|
|
"token",
|
|
"api_key"
|
|
];
|
|
private static readonly Regex SafeEnvironmentVariablePattern = new(
|
|
"^[A-Z][A-Z0-9_]{0,127}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SensitiveUsageTextPattern = new(
|
|
@"(?ix)
|
|
https?://
|
|
| \b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b
|
|
| \b(?:sk|key|token|secret|password)[-_:=][^\s]+",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly HashSet<string> CronMutationMethods =
|
|
[
|
|
"cron.add",
|
|
"cron.update",
|
|
"cron.remove",
|
|
"cron.run"
|
|
];
|
|
private static readonly HashSet<string> AllowedCronPatchFields =
|
|
[
|
|
"name",
|
|
"displayName",
|
|
"agentId",
|
|
"sessionKey",
|
|
"description",
|
|
"enabled",
|
|
"deleteAfterRun",
|
|
"schedule",
|
|
"trigger",
|
|
"sessionTarget",
|
|
"wakeMode",
|
|
"payload",
|
|
"delivery",
|
|
"failureAlert"
|
|
];
|
|
private static readonly Regex DeliveryTargetPattern = new(
|
|
@"(?ix)
|
|
https?://[^\s]+
|
|
| \b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b
|
|
| (?<![\w])\+?\d[\d\s().-]{6,}\d
|
|
| \b(?:telegram|discord|slack|signal|whatsapp|webhook):[^\s,;]+",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
private static readonly OpenClawCapabilityDefinition[] CapabilityDefinitions =
|
|
[
|
|
new("tasks-read", "Task-Ledger lesen", "tasks.list", "operator.read"),
|
|
new("tasks-cancel", "Tasks abbrechen", "tasks.cancel", "operator.write"),
|
|
new("sessions-read", "Sessions lesen", "sessions.list", "operator.read"),
|
|
new("sessions-abort", "Runs abbrechen", "sessions.abort", "operator.write"),
|
|
new("sessions-model", "Session-Modell ändern", "sessions.patch", "operator.write"),
|
|
new("activity-read", "Audit-Aktivität lesen", "audit.activity.list", "operator.read"),
|
|
new("cron-read", "Zeitpläne lesen", "cron.list", "operator.read"),
|
|
new("cron-detail", "Zeitplan-Details lesen", "cron.get", "operator.read"),
|
|
new("cron-history", "Zeitplan-Historie lesen", "cron.runs", "operator.read"),
|
|
new("cron-create", "Zeitplan erstellen", "cron.add", "operator.admin"),
|
|
new("cron-update", "Zeitplan bearbeiten", "cron.update", "operator.admin"),
|
|
new("cron-delete", "Zeitplan löschen", "cron.remove", "operator.admin"),
|
|
new("cron-run", "Zeitplan manuell starten", "cron.run", "operator.admin"),
|
|
new("approvals-read", "Freigaben prüfen", "approval.history", "operator.approvals"),
|
|
new("approvals-resolve", "Freigaben entscheiden", "approval.resolve", "operator.approvals"),
|
|
new("models-read", "Modelle lesen", "models.list", "operator.read"),
|
|
new(
|
|
"models-auth-status",
|
|
"Provider-Anmeldung prüfen",
|
|
"models.authStatus",
|
|
"operator.read"),
|
|
new("agents-read", "Agenten lesen", "agents.list", "operator.read")
|
|
];
|
|
|
|
public OpenClawConnectionDto GetConnection()
|
|
{
|
|
var baseUrl = configuration["Integrations:OpenClaw:BaseUrl"]?.Trim();
|
|
var endpoint = NormalizeEndpoint(baseUrl);
|
|
var credentialConfigured =
|
|
!string.IsNullOrWhiteSpace(configuration["Integrations:OpenClaw:Password"]) ||
|
|
!string.IsNullOrWhiteSpace(configuration["Integrations:OpenClaw:Token"]) ||
|
|
connector.DeviceTokenConfigured;
|
|
var configured = Uri.TryCreate(baseUrl, UriKind.Absolute, out _);
|
|
var connected = connector.ConnectionState == GatewayConnectionState.Connected;
|
|
var requiredVersion = connector.RequiredVersion;
|
|
var versionMatches = string.IsNullOrWhiteSpace(requiredVersion) ||
|
|
string.Equals(requiredVersion, connector.GatewayVersion, StringComparison.OrdinalIgnoreCase);
|
|
|
|
var recovery = connector.PairingRequired
|
|
? BuildPairingRecovery(connector.PairingRequestId)
|
|
: BuildConnectionRecovery(
|
|
configured,
|
|
credentialConfigured,
|
|
connected,
|
|
endpoint,
|
|
versionMatches);
|
|
|
|
return new OpenClawConnectionDto(
|
|
connector.ConnectionState.ToString().ToLowerInvariant(),
|
|
configured,
|
|
credentialConfigured,
|
|
connected,
|
|
endpoint,
|
|
connector.GatewayVersion,
|
|
requiredVersion,
|
|
!string.IsNullOrWhiteSpace(requiredVersion),
|
|
versionMatches,
|
|
connector.ProtocolVersion,
|
|
connector.GrantedScopes.Order(StringComparer.Ordinal).ToArray(),
|
|
connector.AdvertisedEvents.Order(StringComparer.Ordinal).ToArray(),
|
|
connector.LastConnectedAt,
|
|
connector.LastEventAt,
|
|
connector.ReconnectAttempts,
|
|
SafeConnectionMessage(connector.StatusMessage),
|
|
recovery,
|
|
DateTimeOffset.UtcNow,
|
|
connector.DeviceId,
|
|
connector.PairingRequired,
|
|
connector.PairingRequestId);
|
|
}
|
|
|
|
public IReadOnlyList<OpenClawCapabilityDto> GetCapabilities()
|
|
{
|
|
var connected = connector.ConnectionState == GatewayConnectionState.Connected;
|
|
return CapabilityDefinitions
|
|
.Select(definition =>
|
|
{
|
|
var methodAvailable = connected && connector.Supports(definition.Method);
|
|
var scopeAvailable = connected && HasScope(definition.RequiredScope);
|
|
var managementAvailable =
|
|
!CronMutationMethods.Contains(definition.Method) ||
|
|
CronManagementEnabled;
|
|
var available = methodAvailable && scopeAvailable && managementAvailable;
|
|
var state = !connected
|
|
? "disconnected"
|
|
: !methodAvailable
|
|
? "unsupported"
|
|
: !scopeAvailable
|
|
? "forbidden"
|
|
: !managementAvailable
|
|
? "management_disabled"
|
|
: "ready";
|
|
var reason = state switch
|
|
{
|
|
"disconnected" => "Gateway nicht verbunden.",
|
|
"unsupported" => $"Gateway bietet {definition.Method} nicht an.",
|
|
"forbidden" => $"Scope {definition.RequiredScope} fehlt.",
|
|
"management_disabled" => "OpenClaw-Verwaltung ist in Nexus nicht freigegeben.",
|
|
_ => null
|
|
};
|
|
|
|
return new OpenClawCapabilityDto(
|
|
definition.Id,
|
|
definition.Label,
|
|
definition.Method,
|
|
definition.RequiredScope,
|
|
available,
|
|
state,
|
|
reason);
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
public async Task<OpenClawOverviewDto> GetOverviewAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tasksTask = GetTasksAsync(100, null, cancellationToken);
|
|
var sessionsTask = GetSessionsAsync(100, cancellationToken);
|
|
var cronTask = GetCronJobsAsync(100, cancellationToken);
|
|
var approvalsTask = GetApprovalsAsync(100, cancellationToken);
|
|
var activityTask = GetActivityAsync(100, null, cancellationToken);
|
|
var modelsTask = GetModelsAsync(cancellationToken);
|
|
var agentsTask = GetAgentsAsync(cancellationToken);
|
|
|
|
await Task.WhenAll(
|
|
tasksTask,
|
|
sessionsTask,
|
|
cronTask,
|
|
approvalsTask,
|
|
activityTask,
|
|
modelsTask,
|
|
agentsTask);
|
|
|
|
return new OpenClawOverviewDto(
|
|
GetConnection(),
|
|
GetCapabilities(),
|
|
await tasksTask,
|
|
await sessionsTask,
|
|
await cronTask,
|
|
await approvalsTask,
|
|
await activityTask,
|
|
await modelsTask,
|
|
await agentsTask,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawTaskDto>> GetTasksAsync(
|
|
int limit = 100,
|
|
string? cursor = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var parameters = new JsonObject
|
|
{
|
|
["limit"] = Math.Clamp(limit, 1, 500)
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(cursor))
|
|
parameters["cursor"] = cursor.Trim();
|
|
|
|
return InvokeCollectionAsync(
|
|
"tasks.list",
|
|
"operator.read",
|
|
parameters,
|
|
response => ReadItems(response, "tasks", "items")
|
|
.Select(MapTask)
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Id))
|
|
.ToArray(),
|
|
response => ReadString(response, "nextCursor"),
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawSessionDto>> GetSessionsAsync(
|
|
int limit = 100,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var canAbort = connector.Supports("sessions.abort") && HasScope("operator.write");
|
|
return InvokeCollectionAsync(
|
|
"sessions.list",
|
|
"operator.read",
|
|
new { limit = Math.Clamp(limit, 1, 500) },
|
|
response => ReadItems(response, "sessions", "items")
|
|
.Select(item => MapSession(item, canAbort))
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Key))
|
|
.ToArray(),
|
|
_ => null,
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawCronJobDto>> GetCronJobsAsync(
|
|
int limit = 100,
|
|
CancellationToken cancellationToken = default)
|
|
=> GetCronJobsAsync(
|
|
includeDisabled: true,
|
|
limit,
|
|
cursor: null,
|
|
cancellationToken);
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawCronJobDto>> GetCronJobsAsync(
|
|
bool includeDisabled,
|
|
int limit = 100,
|
|
string? cursor = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!TryDecodeCursor(cursor, out var offset))
|
|
{
|
|
return Task.FromResult(EmptyCollection<OpenClawCronJobDto>(
|
|
"invalid",
|
|
"Der Cron-Cursor ist ungültig.",
|
|
"Liste ohne Cursor neu laden."));
|
|
}
|
|
|
|
var canRun =
|
|
CronManagementEnabled &&
|
|
connector.Supports("cron.run") &&
|
|
HasScope("operator.admin");
|
|
var boundedLimit = Math.Clamp(limit, 1, 200);
|
|
return InvokeCollectionAsync(
|
|
"cron.list",
|
|
"operator.read",
|
|
new
|
|
{
|
|
includeDisabled,
|
|
limit = boundedLimit,
|
|
offset
|
|
},
|
|
response => ReadItems(response, "jobs", "items")
|
|
.Select(item => MapCronJob(item, canRun))
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Id))
|
|
.ToArray(),
|
|
response => ReadOffsetPaginationCursor(
|
|
response,
|
|
offset,
|
|
boundedLimit,
|
|
"jobs",
|
|
"items"),
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> GetCronJobAsync(
|
|
string jobId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var normalizedJobId = jobId.Trim();
|
|
var unavailable = OperationUnavailable<OpenClawCronJobDetailDto>(
|
|
"cron.get",
|
|
"operator.read");
|
|
if (unavailable is not null)
|
|
return unavailable;
|
|
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"cron.get",
|
|
new { id = normalizedJobId },
|
|
cancellationToken: cancellationToken);
|
|
var node = ReadNode(response, "job") ?? response;
|
|
if (node is null || string.IsNullOrWhiteSpace(ReadString(node, "id", "jobId")))
|
|
{
|
|
return new OpenClawOperationDto<OpenClawCronJobDetailDto>(
|
|
false,
|
|
"not_found",
|
|
"OpenClaw-Zeitplan wurde nicht gefunden.",
|
|
null,
|
|
"Zeitplanliste aktualisieren und die Job-ID prüfen.",
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
return new OpenClawOperationDto<OpenClawCronJobDetailDto>(
|
|
true,
|
|
"ready",
|
|
"OpenClaw-Zeitplan wurde geladen.",
|
|
MapCronJobDetail(node),
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw cron get failed for {JobId}", normalizedJobId);
|
|
return OperationFailure<OpenClawCronJobDetailDto>(exception);
|
|
}
|
|
}
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawCronRunDto>> GetCronRunsAsync(
|
|
string jobId,
|
|
int limit = 100,
|
|
string? cursor = null,
|
|
string? runId = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!TryDecodeCursor(cursor, out var offset))
|
|
{
|
|
return Task.FromResult(EmptyCollection<OpenClawCronRunDto>(
|
|
"invalid",
|
|
"Der Cron-History-Cursor ist ungültig.",
|
|
"Historie ohne Cursor neu laden."));
|
|
}
|
|
|
|
var boundedLimit = Math.Clamp(limit, 1, 200);
|
|
var parameters = new JsonObject
|
|
{
|
|
["scope"] = "job",
|
|
["id"] = jobId.Trim(),
|
|
["limit"] = boundedLimit,
|
|
["offset"] = offset,
|
|
["sortDir"] = "desc"
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(runId))
|
|
parameters["runId"] = runId.Trim();
|
|
|
|
return InvokeCollectionAsync(
|
|
"cron.runs",
|
|
"operator.read",
|
|
parameters,
|
|
response => ReadItems(response, "entries", "runs", "items")
|
|
.Select(MapCronRun)
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.JobId))
|
|
.ToArray(),
|
|
response => ReadOffsetPaginationCursor(
|
|
response,
|
|
offset,
|
|
boundedLimit,
|
|
"entries",
|
|
"runs",
|
|
"items"),
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<OpenClawCollectionDto<OpenClawApprovalDto>> GetApprovalsAsync(
|
|
int limit = 100,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var disconnected = CollectionUnavailable<OpenClawApprovalDto>(
|
|
"approval.history",
|
|
"operator.approvals");
|
|
if (disconnected is not null)
|
|
return disconnected;
|
|
|
|
var supportedMethods = new[]
|
|
{
|
|
"exec.approval.list",
|
|
"plugin.approval.list",
|
|
"approval.history"
|
|
}.Where(connector.Supports).ToArray();
|
|
|
|
if (supportedMethods.Length == 0)
|
|
{
|
|
return EmptyCollection<OpenClawApprovalDto>(
|
|
"unsupported",
|
|
"Diese OpenClaw-Version bietet keine lesbare Approval-Liste an.",
|
|
"OpenClaw aktualisieren oder approval.history beziehungsweise *.approval.list aktivieren.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var canResolve = connector.Supports("approval.resolve") &&
|
|
HasScope("operator.approvals");
|
|
var calls = supportedMethods.Select(method =>
|
|
connector.InvokeAsync(
|
|
method,
|
|
method == "approval.history"
|
|
? new { limit = Math.Clamp(limit, 1, 200) }
|
|
: new { },
|
|
cancellationToken: cancellationToken)).ToArray();
|
|
|
|
var responses = await Task.WhenAll(calls);
|
|
var unique = new Dictionary<string, OpenClawApprovalDto>(StringComparer.Ordinal);
|
|
foreach (var response in responses)
|
|
{
|
|
foreach (var node in ReadItems(response, "approvals", "items", "history", "pending"))
|
|
{
|
|
var approval = MapApproval(node, canResolve);
|
|
if (!string.IsNullOrWhiteSpace(approval.Id))
|
|
unique[approval.Id] = approval;
|
|
}
|
|
}
|
|
|
|
var items = unique.Values
|
|
.OrderBy(item => string.Equals(item.Status, "pending", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
|
.ThenByDescending(item => item.RequestedAt)
|
|
.Take(Math.Clamp(limit, 1, 200))
|
|
.ToArray();
|
|
|
|
return ReadyCollection(items);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw approval list failed");
|
|
return CollectionFailure<OpenClawApprovalDto>(exception);
|
|
}
|
|
}
|
|
|
|
public async Task<OpenClawCollectionDto<OpenClawActivityDto>> GetActivityAsync(
|
|
int limit = 100,
|
|
string? cursor = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (connector.ConnectionState != GatewayConnectionState.Connected)
|
|
return CollectionFailure<OpenClawActivityDto>(
|
|
new OpenClawGatewayRpcException(
|
|
"GATEWAY_DISCONNECTED",
|
|
"OpenClaw Gateway is not connected.",
|
|
retryable: true));
|
|
|
|
if (!connector.Supports("audit.activity.list"))
|
|
{
|
|
var eventItems = connector.GetRecentEvents(limit)
|
|
.Select(MapGatewayEvent)
|
|
.ToArray();
|
|
return new OpenClawCollectionDto<OpenClawActivityDto>(
|
|
eventItems.Length == 0 ? "unsupported" : "ready",
|
|
eventItems,
|
|
null,
|
|
eventItems.Length == 0
|
|
? "OpenClaw bietet audit.activity.list nicht an."
|
|
: "Audit-Ledger nicht verfügbar; echte Gateway-Events werden angezeigt.",
|
|
eventItems.Length == 0
|
|
? "OpenClaw aktualisieren oder Audit-Ledger aktivieren."
|
|
: null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
var parameters = new JsonObject
|
|
{
|
|
["limit"] = Math.Clamp(limit, 1, 500)
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(cursor))
|
|
parameters["cursor"] = cursor.Trim();
|
|
|
|
return await InvokeCollectionAsync(
|
|
"audit.activity.list",
|
|
"operator.read",
|
|
parameters,
|
|
response => ReadItems(response, "events", "items")
|
|
.Select(MapActivity)
|
|
.ToArray(),
|
|
response => ReadString(response, "nextCursor"),
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawModelDto>> GetModelsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return InvokeCollectionAsync(
|
|
"models.list",
|
|
"operator.read",
|
|
new { view = "configured" },
|
|
response => ReadItems(response, "models", "items")
|
|
.Select(MapModel)
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Id))
|
|
.ToArray(),
|
|
_ => null,
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawModelAuthProviderDto>> GetModelAuthStatusAsync(
|
|
bool refresh = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return InvokeCollectionAsync(
|
|
"models.authStatus",
|
|
"operator.read",
|
|
new { refresh },
|
|
response => ReadItems(response, "providers", "items")
|
|
.Select(MapModelAuthProvider)
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Provider))
|
|
.OrderBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray(),
|
|
_ => null,
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<OpenClawCollectionDto<OpenClawAgentDto>> GetAgentsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return InvokeCollectionAsync(
|
|
"agents.list",
|
|
"operator.read",
|
|
new { },
|
|
response => ReadItems(response, "agents", "items")
|
|
.Select(MapAgent)
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Id))
|
|
.ToArray(),
|
|
_ => null,
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<OpenClawTaskDto>> CancelTaskAsync(
|
|
string taskId,
|
|
string? reason,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var normalizedTaskId = taskId.Trim();
|
|
var taskRef = new EntityRefDto("openclaw-task", normalizedTaskId);
|
|
var writeUnavailable =
|
|
await WriteMutationUnavailableAsync<OpenClawTaskDto>(
|
|
"tasks.cancel",
|
|
cancellationToken,
|
|
"operator.write");
|
|
if (writeUnavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
writeUnavailable,
|
|
invocationContext,
|
|
taskRef);
|
|
|
|
var normalizedReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
|
var unavailable = OperationUnavailable<OpenClawTaskDto>("tasks.cancel", "operator.write");
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(unavailable, invocationContext, taskRef);
|
|
|
|
return await ExecuteMutationAsync(
|
|
"tasks.cancel",
|
|
"task",
|
|
normalizedTaskId,
|
|
[normalizedReason ?? string.Empty],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
var parameters = new JsonObject { ["taskId"] = normalizedTaskId };
|
|
if (normalizedReason is not null)
|
|
parameters["reason"] = normalizedReason;
|
|
|
|
var response = await connector.InvokeAsync(
|
|
"tasks.cancel",
|
|
parameters,
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var found = ReadBool(response, "found") ?? true;
|
|
var cancelled = ReadBool(response, "cancelled") ?? false;
|
|
var taskNode = ReadNode(response, "task");
|
|
var task = taskNode is null ? null : MapTask(taskNode);
|
|
var message = !found
|
|
? "OpenClaw-Task wurde nicht gefunden."
|
|
: cancelled
|
|
? "OpenClaw-Task wurde abgebrochen."
|
|
: ReadString(response, "reason") ?? "OpenClaw konnte den Task nicht abbrechen.";
|
|
|
|
return new OpenClawOperationDto<OpenClawTaskDto>(
|
|
found && cancelled,
|
|
found && cancelled ? "completed" : "rejected",
|
|
message,
|
|
task,
|
|
found ? null : "Task-Liste aktualisieren und die Task-ID prüfen.",
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw task cancellation failed for {TaskId}", taskId);
|
|
return OperationFailure<OpenClawTaskDto>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<object>> AbortSessionAsync(
|
|
string sessionKey,
|
|
string? runId,
|
|
bool clearQueued,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var normalizedSessionKey = sessionKey.Trim();
|
|
var sessionRef = new EntityRefDto("session", normalizedSessionKey);
|
|
var writeUnavailable =
|
|
await WriteMutationUnavailableAsync<object>(
|
|
"sessions.abort",
|
|
cancellationToken,
|
|
"operator.write");
|
|
if (writeUnavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
writeUnavailable,
|
|
invocationContext,
|
|
sessionRef);
|
|
|
|
var normalizedRunId = string.IsNullOrWhiteSpace(runId) ? null : runId.Trim();
|
|
var unavailable = OperationUnavailable<object>("sessions.abort", "operator.write");
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(unavailable, invocationContext, sessionRef);
|
|
|
|
return await ExecuteMutationAsync(
|
|
"sessions.abort",
|
|
"session",
|
|
normalizedSessionKey,
|
|
[normalizedRunId ?? string.Empty, clearQueued.ToString(CultureInfo.InvariantCulture)],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
var parameters = new JsonObject
|
|
{
|
|
["key"] = normalizedSessionKey,
|
|
["clearQueued"] = clearQueued
|
|
};
|
|
if (normalizedRunId is not null)
|
|
parameters["runId"] = normalizedRunId;
|
|
|
|
var response = await connector.InvokeAsync(
|
|
"sessions.abort",
|
|
parameters,
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var data = new Dictionary<string, object?>
|
|
{
|
|
["sessionKey"] = normalizedSessionKey,
|
|
["runId"] = ReadString(response, "runId") ?? normalizedRunId,
|
|
["aborted"] = ReadBool(response, "aborted") ?? ReadBool(response, "ok") ?? true,
|
|
["queuedCleared"] = clearQueued
|
|
};
|
|
|
|
return new OpenClawOperationDto<object>(
|
|
true,
|
|
"completed",
|
|
clearQueued
|
|
? "Aktiver Run und wartende Follow-ups wurden gestoppt."
|
|
: "Aktiver Run wurde gestoppt.",
|
|
data,
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw session abort failed");
|
|
return OperationFailure<object>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<object>> PatchSessionModelAsync(
|
|
string sessionKey,
|
|
string model,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var normalizedSessionKey = sessionKey.Trim();
|
|
var sessionRef = new EntityRefDto("session", normalizedSessionKey);
|
|
var writeUnavailable =
|
|
await WriteMutationUnavailableAsync<object>(
|
|
"sessions.patch",
|
|
cancellationToken,
|
|
"operator.write");
|
|
if (writeUnavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
writeUnavailable,
|
|
invocationContext,
|
|
sessionRef);
|
|
|
|
var normalizedModel = model.Trim();
|
|
var unavailable = OperationUnavailable<object>("sessions.patch", "operator.write");
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(unavailable, invocationContext, sessionRef);
|
|
|
|
return await ExecuteMutationAsync(
|
|
"sessions.patch",
|
|
"session",
|
|
normalizedSessionKey,
|
|
[normalizedModel],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"sessions.patch",
|
|
new
|
|
{
|
|
key = normalizedSessionKey,
|
|
model = normalizedModel
|
|
},
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var resolvedModel = ReadString(response, "model", "resolvedModel") ?? normalizedModel;
|
|
var provider = ReadString(response, "provider") ?? ExtractProvider(resolvedModel);
|
|
var data = new Dictionary<string, object?>
|
|
{
|
|
["sessionKey"] = normalizedSessionKey,
|
|
["model"] = resolvedModel,
|
|
["provider"] = provider
|
|
};
|
|
|
|
return new OpenClawOperationDto<object>(
|
|
true,
|
|
"completed",
|
|
"Session-Modell wurde in OpenClaw aktualisiert.",
|
|
data,
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw session model patch failed");
|
|
return OperationFailure<object>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> CreateCronJobAsync(
|
|
CreateOpenClawCronJobRequest request,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var requestedCronRef = new EntityRefDto(
|
|
"cron",
|
|
string.IsNullOrWhiteSpace(request.DeclarationKey) ? "new" : request.DeclarationKey.Trim(),
|
|
string.IsNullOrWhiteSpace(request.DisplayName) ? request.Name?.Trim() : request.DisplayName.Trim());
|
|
var unavailable =
|
|
await WriteMutationUnavailableAsync<OpenClawCronJobDetailDto>(
|
|
"cron.add",
|
|
cancellationToken);
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(unavailable, invocationContext, requestedCronRef);
|
|
|
|
var validation = ValidateCreateCronRequest(request);
|
|
if (validation is not null)
|
|
return EnrichUnavailableOrInvalid(validation, invocationContext, requestedCronRef);
|
|
|
|
var parameters = BuildCronCreateParameters(request);
|
|
var targetId = !string.IsNullOrWhiteSpace(request.DeclarationKey)
|
|
? request.DeclarationKey.Trim()
|
|
: $"create:{OpenClawInvocationContextFactory.Hash(CanonicalJson(parameters))[..16]}";
|
|
var targetRef = new EntityRefDto("cron", targetId, requestedCronRef.Label);
|
|
if (!AllowCommandCron && IsRestrictedCronDefinition(parameters))
|
|
{
|
|
return EnrichUnavailableOrInvalid(
|
|
RestrictedCronOperation<OpenClawCronJobDetailDto>(),
|
|
invocationContext,
|
|
targetRef);
|
|
}
|
|
|
|
return await ExecuteMutationAsync(
|
|
"cron.add",
|
|
"cron-job",
|
|
targetId,
|
|
[CanonicalJson(parameters)],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"cron.add",
|
|
parameters,
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var jobNode = ReadNode(response, "job") ?? response;
|
|
if (jobNode is null || string.IsNullOrWhiteSpace(ReadString(jobNode, "id", "jobId")))
|
|
{
|
|
return new OpenClawOperationDto<OpenClawCronJobDetailDto>(
|
|
false,
|
|
"rejected",
|
|
"OpenClaw hat keinen Zeitplan bestätigt.",
|
|
null,
|
|
"Zeitplanliste aktualisieren und OpenClaw-Logs prüfen.",
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
var created = ReadBool(response, "created");
|
|
return new OpenClawOperationDto<OpenClawCronJobDetailDto>(
|
|
true,
|
|
"completed",
|
|
created == false
|
|
? "Deklarativer OpenClaw-Zeitplan wurde aktualisiert."
|
|
: "OpenClaw-Zeitplan wurde erstellt.",
|
|
MapCronJobDetail(jobNode),
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw cron create failed");
|
|
return OperationFailure<OpenClawCronJobDetailDto>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> PatchCronJobAsync(
|
|
string jobId,
|
|
JsonObject patch,
|
|
string? expectedHash,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var normalizedJobId = jobId.Trim();
|
|
var unavailable =
|
|
await WriteMutationUnavailableAsync<OpenClawCronJobDetailDto>(
|
|
"cron.update",
|
|
cancellationToken);
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
unavailable,
|
|
invocationContext,
|
|
new EntityRefDto("cron", normalizedJobId));
|
|
|
|
var validation = ValidateCronPatch(patch);
|
|
if (validation is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
validation,
|
|
invocationContext,
|
|
new EntityRefDto("cron", normalizedJobId));
|
|
|
|
return await ExecuteMutationAsync(
|
|
"cron.update",
|
|
"cron-job",
|
|
normalizedJobId,
|
|
[CanonicalJson(patch), NormalizeExpectedHash(expectedHash) ?? string.Empty],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
var current = await ReadCronJobNodeAsync(normalizedJobId, cancellationToken);
|
|
if (current is null)
|
|
return CronNotFound<OpenClawCronJobDetailDto>();
|
|
if (!MatchesExpectedHash(current, expectedHash))
|
|
return CronConflict<OpenClawCronJobDetailDto>();
|
|
|
|
var onlyDisablesRestrictedJob =
|
|
patch.Count == 1 &&
|
|
ReadBool(patch, "enabled") == false;
|
|
var effectiveDefinition = current.DeepClone();
|
|
if (effectiveDefinition is JsonObject effectiveObject)
|
|
{
|
|
if (ReadNode(patch, "schedule") is { } patchedSchedule)
|
|
effectiveObject["schedule"] = patchedSchedule.DeepClone();
|
|
if (ReadNode(patch, "payload") is { } patchedPayload)
|
|
effectiveObject["payload"] = patchedPayload.DeepClone();
|
|
}
|
|
if (!AllowCommandCron &&
|
|
IsRestrictedCronDefinition(effectiveDefinition) &&
|
|
!onlyDisablesRestrictedJob)
|
|
{
|
|
return RestrictedCronOperation<OpenClawCronJobDetailDto>();
|
|
}
|
|
|
|
var parameters = new JsonObject
|
|
{
|
|
["id"] = normalizedJobId,
|
|
["patch"] = patch.DeepClone()
|
|
};
|
|
var response = await connector.InvokeAsync(
|
|
"cron.update",
|
|
parameters,
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var jobNode = ReadNode(response, "job") ?? response;
|
|
if (jobNode is null || string.IsNullOrWhiteSpace(ReadString(jobNode, "id", "jobId")))
|
|
{
|
|
return new OpenClawOperationDto<OpenClawCronJobDetailDto>(
|
|
false,
|
|
"rejected",
|
|
"OpenClaw hat die Zeitplanänderung nicht bestätigt.",
|
|
null,
|
|
"Zeitplandetail aktualisieren und OpenClaw-Logs prüfen.",
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
return new OpenClawOperationDto<OpenClawCronJobDetailDto>(
|
|
true,
|
|
"completed",
|
|
"OpenClaw-Zeitplan wurde aktualisiert.",
|
|
MapCronJobDetail(jobNode),
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(
|
|
exception,
|
|
"OpenClaw cron update failed for {JobId}",
|
|
normalizedJobId);
|
|
return OperationFailure<OpenClawCronJobDetailDto>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<object>> DeleteCronJobAsync(
|
|
string jobId,
|
|
string? expectedHash = null,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var normalizedJobId = jobId.Trim();
|
|
var unavailable = await WriteMutationUnavailableAsync<object>(
|
|
"cron.remove",
|
|
cancellationToken);
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
unavailable,
|
|
invocationContext,
|
|
new EntityRefDto("cron", normalizedJobId));
|
|
|
|
return await ExecuteMutationAsync(
|
|
"cron.remove",
|
|
"cron-job",
|
|
normalizedJobId,
|
|
[NormalizeExpectedHash(expectedHash) ?? string.Empty],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(expectedHash))
|
|
{
|
|
var current = await ReadCronJobNodeAsync(normalizedJobId, cancellationToken);
|
|
if (current is null)
|
|
return CronNotFound<object>();
|
|
if (!MatchesExpectedHash(current, expectedHash))
|
|
return CronConflict<object>();
|
|
}
|
|
|
|
var response = await connector.InvokeAsync(
|
|
"cron.remove",
|
|
new { id = normalizedJobId },
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var removed = ReadBool(response, "removed") ?? ReadBool(response, "ok") ?? false;
|
|
return new OpenClawOperationDto<object>(
|
|
removed,
|
|
removed ? "completed" : "not_found",
|
|
removed
|
|
? "OpenClaw-Zeitplan wurde gelöscht."
|
|
: "OpenClaw-Zeitplan wurde nicht gefunden.",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["jobId"] = normalizedJobId,
|
|
["removed"] = removed
|
|
},
|
|
removed ? null : "Zeitplanliste aktualisieren.",
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(
|
|
exception,
|
|
"OpenClaw cron delete failed for {JobId}",
|
|
normalizedJobId);
|
|
return OperationFailure<object>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
public Task<OpenClawOperationDto<object>> RunCronJobAsync(
|
|
string jobId,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
=> RunCronJobAsync(
|
|
jobId,
|
|
expectedHash: null,
|
|
cancellationToken,
|
|
invocationContext);
|
|
|
|
public async Task<OpenClawOperationDto<object>> RunCronJobAsync(
|
|
string jobId,
|
|
string? expectedHash,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var normalizedJobId = jobId.Trim();
|
|
var unavailable = await WriteMutationUnavailableAsync<object>(
|
|
"cron.run",
|
|
cancellationToken);
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
unavailable,
|
|
invocationContext,
|
|
new EntityRefDto("cron", normalizedJobId));
|
|
|
|
return await ExecuteMutationAsync(
|
|
"cron.run",
|
|
"cron-job",
|
|
normalizedJobId,
|
|
["force", NormalizeExpectedHash(expectedHash) ?? string.Empty],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
if (!AllowCommandCron || !string.IsNullOrWhiteSpace(expectedHash))
|
|
{
|
|
var current = await ReadCronJobNodeAsync(normalizedJobId, cancellationToken);
|
|
if (current is null)
|
|
return CronNotFound<object>();
|
|
if (!MatchesExpectedHash(current, expectedHash))
|
|
return CronConflict<object>();
|
|
if (!AllowCommandCron && IsRestrictedCronDefinition(current))
|
|
return RestrictedCronOperation<object>();
|
|
}
|
|
|
|
var response = await connector.InvokeAsync(
|
|
"cron.run",
|
|
new { id = normalizedJobId, mode = "force" },
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var runId = ReadString(response, "runId");
|
|
var enqueued =
|
|
ReadBool(response, "enqueued") ??
|
|
(!string.IsNullOrWhiteSpace(runId)
|
|
? true
|
|
: ReadBool(response, "ok") ?? false);
|
|
var data = new Dictionary<string, object?>
|
|
{
|
|
["jobId"] = normalizedJobId,
|
|
["enqueued"] = enqueued,
|
|
["runId"] = runId
|
|
};
|
|
|
|
return new OpenClawOperationDto<object>(
|
|
enqueued,
|
|
enqueued ? "queued" : "rejected",
|
|
enqueued
|
|
? "Cron-Run wurde in OpenClaw eingereiht."
|
|
: "OpenClaw hat keinen Cron-Run eingereiht.",
|
|
data,
|
|
enqueued ? null : "Zeitplanstatus aktualisieren und OpenClaw-Logs prüfen.",
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw cron run failed for {JobId}", jobId);
|
|
return OperationFailure<object>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task<OpenClawOperationDto<OpenClawApprovalDto>> ResolveApprovalAsync(
|
|
string approvalId,
|
|
string kind,
|
|
string decision,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var normalizedApprovalId = approvalId.Trim();
|
|
var approvalRef = new EntityRefDto("approval", normalizedApprovalId);
|
|
var unavailable =
|
|
await WriteMutationUnavailableAsync<OpenClawApprovalDto>(
|
|
"approval.resolve",
|
|
cancellationToken,
|
|
"operator.approvals");
|
|
if (unavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
unavailable,
|
|
invocationContext,
|
|
approvalRef);
|
|
|
|
var normalizedDecision = decision.Trim().ToLowerInvariant();
|
|
if (!ApprovalDecisions.Contains(normalizedDecision, StringComparer.Ordinal))
|
|
{
|
|
return EnrichUnavailableOrInvalid(
|
|
new OpenClawOperationDto<OpenClawApprovalDto>(
|
|
false,
|
|
"invalid",
|
|
"Ungültige Approval-Entscheidung.",
|
|
null,
|
|
"allow-once, allow-always oder deny verwenden.",
|
|
DateTimeOffset.UtcNow),
|
|
invocationContext,
|
|
approvalRef);
|
|
}
|
|
|
|
var normalizedKind = kind.Trim().ToLowerInvariant();
|
|
var capabilityUnavailable = OperationUnavailable<OpenClawApprovalDto>(
|
|
"approval.resolve",
|
|
"operator.approvals");
|
|
if (capabilityUnavailable is not null)
|
|
return EnrichUnavailableOrInvalid(
|
|
capabilityUnavailable,
|
|
invocationContext,
|
|
approvalRef);
|
|
|
|
return await ExecuteMutationAsync(
|
|
"approval.resolve",
|
|
"approval",
|
|
normalizedApprovalId,
|
|
[normalizedKind, normalizedDecision],
|
|
invocationContext,
|
|
cancellationToken,
|
|
async context =>
|
|
{
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"approval.resolve",
|
|
new
|
|
{
|
|
id = normalizedApprovalId,
|
|
kind = normalizedKind,
|
|
decision = normalizedDecision
|
|
},
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: context with { IncludeIdempotencyParameter = false });
|
|
var approvalNode = ReadNode(response, "approval", "result") ?? response;
|
|
var approval = approvalNode is null
|
|
? null
|
|
: MapApproval(approvalNode, canResolve: false);
|
|
|
|
return new OpenClawOperationDto<OpenClawApprovalDto>(
|
|
true,
|
|
"completed",
|
|
normalizedDecision == "deny"
|
|
? "OpenClaw-Aktion wurde abgelehnt."
|
|
: "OpenClaw-Aktion wurde freigegeben.",
|
|
approval,
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw approval resolution failed for {ApprovalId}", approvalId);
|
|
return OperationFailure<OpenClawApprovalDto>(exception);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task<OpenClawOperationDto<T>> ExecuteMutationAsync<T>(
|
|
string method,
|
|
string targetType,
|
|
string targetId,
|
|
IReadOnlyCollection<string> intentValues,
|
|
OpenClawInvocationContext? requestedContext,
|
|
CancellationToken cancellationToken,
|
|
Func<OpenClawInvocationContext, Task<OpenClawOperationDto<T>>> operation)
|
|
{
|
|
OpenClawInvocationContext context;
|
|
try
|
|
{
|
|
context = ResolveInvocationContext(requestedContext);
|
|
}
|
|
catch (ArgumentException exception)
|
|
{
|
|
return new OpenClawOperationDto<T>(
|
|
false,
|
|
"invalid",
|
|
"Ungültige Korrelations- oder Idempotenz-Metadaten.",
|
|
default,
|
|
exception.Message,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
var intentFingerprint = OpenClawInvocationContextFactory.Hash(string.Join(
|
|
'\u001f',
|
|
new[] { method, targetType, targetId, context.Actor }.Concat(intentValues)));
|
|
var descriptor = new OpenClawOperationDescriptor(
|
|
method,
|
|
targetType,
|
|
targetId,
|
|
intentFingerprint);
|
|
|
|
if (operationAuditStore is not null)
|
|
{
|
|
OpenClawOperationClaim claim;
|
|
try
|
|
{
|
|
claim = await operationAuditStore.ClaimAsync(
|
|
context,
|
|
descriptor,
|
|
cancellationToken);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(
|
|
exception,
|
|
"OpenClaw mutation {Method} was blocked because its local audit claim failed",
|
|
method);
|
|
return EnrichOperation(
|
|
new OpenClawOperationDto<T>(
|
|
false,
|
|
"audit_unavailable",
|
|
"OpenClaw-Aktion wurde vor der Ausführung blockiert.",
|
|
default,
|
|
"Lokales Operation-Audit reparieren; die Aktion wurde nicht an OpenClaw gesendet.",
|
|
DateTimeOffset.UtcNow),
|
|
context,
|
|
BuildTargetRef(targetType, targetId, default(T)),
|
|
BuildAffectedRefs(default(T)));
|
|
}
|
|
|
|
if (claim.Disposition != OpenClawOperationClaimDisposition.Started)
|
|
{
|
|
return BuildClaimResult<T>(
|
|
claim,
|
|
context,
|
|
BuildTargetRef(targetType, targetId, default(T)));
|
|
}
|
|
}
|
|
|
|
var result = await operation(context);
|
|
if (operationAuditStore is not null)
|
|
{
|
|
try
|
|
{
|
|
await operationAuditStore.CompleteAsync(
|
|
context,
|
|
descriptor,
|
|
result.Ok,
|
|
result.State,
|
|
result.Ok
|
|
? "OpenClaw-Aktion wurde abgeschlossen."
|
|
: "OpenClaw-Aktion wurde nicht bestätigt.",
|
|
result.Ok ? null : result.State,
|
|
cancellationToken);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(
|
|
exception,
|
|
"OpenClaw mutation {Method} completed but its local audit completion failed",
|
|
method);
|
|
result = result with
|
|
{
|
|
State = result.Ok ? "completed_audit_pending" : "audit_pending",
|
|
Recovery = "Gateway-Ergebnis liegt vor, aber das lokale Operation-Audit muss geprüft werden."
|
|
};
|
|
}
|
|
}
|
|
|
|
return EnrichOperation(
|
|
result,
|
|
context,
|
|
BuildTargetRef(targetType, targetId, result.Data),
|
|
BuildAffectedRefs(result.Data));
|
|
}
|
|
|
|
private OpenClawInvocationContext ResolveInvocationContext(
|
|
OpenClawInvocationContext? requestedContext)
|
|
{
|
|
if (requestedContext is not null)
|
|
{
|
|
return OpenClawInvocationContextFactory.Create(
|
|
requestedContext.Actor,
|
|
requestedContext.IdempotencyKey,
|
|
requestedContext.CorrelationId,
|
|
requestedContext.TraceParent,
|
|
requestedContext.IncludeIdempotencyParameter);
|
|
}
|
|
|
|
var httpContext = httpContextAccessor?.HttpContext;
|
|
var actor = ResolveActor(httpContext?.User);
|
|
var idempotencyKey = ReadHeader(httpContext, "Idempotency-Key");
|
|
var correlationId =
|
|
ReadHeader(httpContext, "X-Correlation-ID") ??
|
|
httpContext?.TraceIdentifier;
|
|
var traceParent = ReadHeader(httpContext, "traceparent");
|
|
|
|
return OpenClawInvocationContextFactory.Create(
|
|
actor,
|
|
idempotencyKey,
|
|
correlationId,
|
|
traceParent);
|
|
}
|
|
|
|
private static OpenClawOperationDto<T> BuildClaimResult<T>(
|
|
OpenClawOperationClaim claim,
|
|
OpenClawInvocationContext context,
|
|
EntityRefDto? primaryRef)
|
|
{
|
|
var result = claim.Disposition switch
|
|
{
|
|
OpenClawOperationClaimDisposition.Replayed => new OpenClawOperationDto<T>(
|
|
claim.PreviousOk == true,
|
|
claim.PreviousOk == true ? "replayed" : claim.PreviousState ?? "replayed",
|
|
claim.PreviousMessage ?? "Das bereits protokollierte Ergebnis wurde wiederverwendet.",
|
|
default,
|
|
claim.PreviousOk == true ? null : "Für einen neuen Versuch einen neuen Idempotency-Key verwenden.",
|
|
DateTimeOffset.UtcNow),
|
|
OpenClawOperationClaimDisposition.InDoubt => new OpenClawOperationDto<T>(
|
|
false,
|
|
"in_doubt",
|
|
claim.PreviousMessage ?? "Der frühere Ausführungsstatus ist unklar.",
|
|
default,
|
|
"OpenClaw- und Nexus-Audit prüfen; nicht automatisch erneut senden.",
|
|
DateTimeOffset.UtcNow),
|
|
_ => new OpenClawOperationDto<T>(
|
|
false,
|
|
"idempotency_conflict",
|
|
claim.PreviousMessage ?? "Der Idempotency-Key kollidiert mit einer anderen Aktion.",
|
|
default,
|
|
"Für eine andere Aktion einen neuen Idempotency-Key verwenden.",
|
|
DateTimeOffset.UtcNow)
|
|
};
|
|
return EnrichOperation(result, context, primaryRef);
|
|
}
|
|
|
|
private static OpenClawOperationDto<T> EnrichOperation<T>(
|
|
OpenClawOperationDto<T> operation,
|
|
OpenClawInvocationContext context,
|
|
EntityRefDto? primaryRef = null,
|
|
IEnumerable<EntityRefDto>? affectedRefs = null)
|
|
=> operation with
|
|
{
|
|
OperationId = context.CorrelationId,
|
|
CorrelationId = context.CorrelationId,
|
|
IdempotencyKey = context.IdempotencyKey,
|
|
TraceParent = context.TraceParent,
|
|
Actor = context.Actor,
|
|
Operation = OperationResultFactory.FromInvocation(
|
|
context,
|
|
operation.State,
|
|
primaryRef,
|
|
affectedRefs: affectedRefs)
|
|
};
|
|
|
|
private OpenClawOperationDto<T> EnrichUnavailableOrInvalid<T>(
|
|
OpenClawOperationDto<T> operation,
|
|
OpenClawInvocationContext? requestedContext,
|
|
EntityRefDto? primaryRef = null)
|
|
{
|
|
try
|
|
{
|
|
return EnrichOperation(
|
|
operation,
|
|
ResolveInvocationContext(requestedContext),
|
|
primaryRef);
|
|
}
|
|
catch (ArgumentException exception)
|
|
{
|
|
return InvalidInvocationMetadata<T>(exception);
|
|
}
|
|
}
|
|
|
|
private static EntityRefDto BuildTargetRef<T>(
|
|
string targetType,
|
|
string targetId,
|
|
T? data)
|
|
{
|
|
if (data is OpenClawCronJobDetailDto cron)
|
|
{
|
|
return new EntityRefDto(
|
|
"cron",
|
|
cron.Id,
|
|
cron.DisplayName ?? cron.Name);
|
|
}
|
|
|
|
if (data is OpenClawTaskDto task)
|
|
return new EntityRefDto("openclaw-task", task.Id, task.Title);
|
|
|
|
if (data is OpenClawApprovalDto approval)
|
|
return new EntityRefDto("approval", approval.Id, approval.Title);
|
|
|
|
var entityType = targetType switch
|
|
{
|
|
"cron-job" => "cron",
|
|
"task" => "openclaw-task",
|
|
"session" => "session",
|
|
_ => targetType
|
|
};
|
|
return new EntityRefDto(entityType, targetId);
|
|
}
|
|
|
|
private static IReadOnlyList<EntityRefDto> BuildAffectedRefs<T>(T? data)
|
|
{
|
|
var affected = new List<EntityRefDto>();
|
|
switch (data)
|
|
{
|
|
case OpenClawCronJobDetailDto cron:
|
|
if (!string.IsNullOrWhiteSpace(cron.AgentId))
|
|
affected.Add(new EntityRefDto("agent", cron.AgentId));
|
|
if (!string.IsNullOrWhiteSpace(cron.SessionKey))
|
|
affected.Add(new EntityRefDto("session", cron.SessionKey));
|
|
break;
|
|
case OpenClawTaskDto task:
|
|
if (!string.IsNullOrWhiteSpace(task.AgentId))
|
|
affected.Add(new EntityRefDto("agent", task.AgentId));
|
|
if (!string.IsNullOrWhiteSpace(task.SessionKey))
|
|
affected.Add(new EntityRefDto("session", task.SessionKey));
|
|
if (Guid.TryParse(task.RunId, out var taskRunId))
|
|
affected.Add(new EntityRefDto("run", taskRunId.ToString()));
|
|
break;
|
|
case OpenClawApprovalDto approval:
|
|
if (!string.IsNullOrWhiteSpace(approval.AgentId))
|
|
affected.Add(new EntityRefDto("agent", approval.AgentId));
|
|
if (!string.IsNullOrWhiteSpace(approval.SessionKey))
|
|
affected.Add(new EntityRefDto("session", approval.SessionKey));
|
|
break;
|
|
}
|
|
|
|
return affected;
|
|
}
|
|
|
|
private static OpenClawOperationDto<T> InvalidInvocationMetadata<T>(
|
|
ArgumentException exception)
|
|
=> new(
|
|
false,
|
|
"invalid",
|
|
"Ungültige Korrelations- oder Idempotenz-Metadaten.",
|
|
default,
|
|
exception.Message,
|
|
DateTimeOffset.UtcNow);
|
|
|
|
private static string ResolveActor(ClaimsPrincipal? principal)
|
|
{
|
|
if (principal?.Identity?.IsAuthenticated != true)
|
|
return "nexus-system";
|
|
|
|
return principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ??
|
|
principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ??
|
|
principal.Identity.Name ??
|
|
"nexus-authenticated-user";
|
|
}
|
|
|
|
private static string? ReadHeader(HttpContext? context, string name)
|
|
{
|
|
if (context is null || !context.Request.Headers.TryGetValue(name, out var values))
|
|
return null;
|
|
var value = values.FirstOrDefault()?.Trim();
|
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
|
}
|
|
|
|
private async Task<OpenClawCollectionDto<T>> InvokeCollectionAsync<T>(
|
|
string method,
|
|
string requiredScope,
|
|
object parameters,
|
|
Func<JsonNode?, IReadOnlyList<T>> mapItems,
|
|
Func<JsonNode?, string?> readCursor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var unavailable = CollectionUnavailable<T>(method, requiredScope);
|
|
if (unavailable is not null)
|
|
return unavailable;
|
|
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
method,
|
|
parameters,
|
|
cancellationToken: cancellationToken);
|
|
return new OpenClawCollectionDto<T>(
|
|
"ready",
|
|
mapItems(response),
|
|
readCursor(response),
|
|
null,
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw method {Method} failed", method);
|
|
return CollectionFailure<T>(exception);
|
|
}
|
|
}
|
|
|
|
private OpenClawCollectionDto<T>? CollectionUnavailable<T>(
|
|
string method,
|
|
string requiredScope)
|
|
{
|
|
if (connector.ConnectionState != GatewayConnectionState.Connected)
|
|
{
|
|
return EmptyCollection<T>(
|
|
"disconnected",
|
|
"OpenClaw Gateway ist nicht verbunden.",
|
|
GetConnection().Recovery);
|
|
}
|
|
|
|
if (!connector.Supports(method))
|
|
{
|
|
return EmptyCollection<T>(
|
|
"unsupported",
|
|
$"Die verbundene OpenClaw-Version bietet {method} nicht an.",
|
|
"OpenClaw-Version und Gateway-Featureliste prüfen.");
|
|
}
|
|
|
|
if (!HasScope(requiredScope))
|
|
{
|
|
return EmptyCollection<T>(
|
|
"forbidden",
|
|
$"Nexus fehlt der Scope {requiredScope}.",
|
|
"Nexus mit dem benötigten Operator-Scope verbinden.");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private OpenClawOperationDto<T>? OperationUnavailable<T>(
|
|
string method,
|
|
string requiredScope)
|
|
{
|
|
var collection = CollectionUnavailable<T>(method, requiredScope);
|
|
return collection is null
|
|
? null
|
|
: new OpenClawOperationDto<T>(
|
|
false,
|
|
collection.State,
|
|
collection.Message ?? "OpenClaw-Aktion ist nicht verfügbar.",
|
|
default,
|
|
collection.Recovery,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private async Task<OpenClawOperationDto<T>?> WriteMutationUnavailableAsync<T>(
|
|
string method,
|
|
CancellationToken cancellationToken,
|
|
string requiredScope = "operator.admin")
|
|
{
|
|
var decision = await writeGate.EvaluateAsync(
|
|
method,
|
|
requiredScope,
|
|
cancellationToken);
|
|
return decision.Allowed
|
|
? null
|
|
: new OpenClawOperationDto<T>(
|
|
false,
|
|
decision.State,
|
|
decision.Message,
|
|
default,
|
|
decision.Recovery,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private bool CronManagementEnabled =>
|
|
managementState?.Enabled ??
|
|
configuration.GetValue<bool?>("Integrations:OpenClaw:ManagementEnabled") ??
|
|
configuration.GetValue<bool?>("OpenClaw:ManagementEnabled") ??
|
|
false;
|
|
|
|
private bool AllowCommandCron =>
|
|
configuration.GetValue<bool?>("Integrations:OpenClaw:AllowCommandCron") ??
|
|
configuration.GetValue<bool?>("OpenClaw:AllowCommandCron") ??
|
|
false;
|
|
|
|
private static OpenClawOperationDto<OpenClawCronJobDetailDto>? ValidateCreateCronRequest(
|
|
CreateOpenClawCronJobRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
|
return InvalidCron<OpenClawCronJobDetailDto>("Ein Zeitplanname ist erforderlich.");
|
|
if (request.Schedule is null ||
|
|
string.IsNullOrWhiteSpace(ReadString(request.Schedule, "kind")))
|
|
{
|
|
return InvalidCron<OpenClawCronJobDetailDto>(
|
|
"Ein unterstützter schedule.kind ist erforderlich.");
|
|
}
|
|
if (request.Payload is null ||
|
|
string.IsNullOrWhiteSpace(ReadString(request.Payload, "kind")))
|
|
{
|
|
return InvalidCron<OpenClawCronJobDetailDto>(
|
|
"Ein unterstützter payload.kind ist erforderlich.");
|
|
}
|
|
|
|
var sessionTarget = request.SessionTarget?.Trim();
|
|
if (sessionTarget is not ("main" or "isolated" or "current") &&
|
|
!(sessionTarget?.StartsWith("session:", StringComparison.Ordinal) ?? false))
|
|
{
|
|
return InvalidCron<OpenClawCronJobDetailDto>(
|
|
"sessionTarget muss main, isolated, current oder session:<id> sein.");
|
|
}
|
|
if (request.WakeMode?.Trim() is not ("now" or "next-heartbeat"))
|
|
{
|
|
return InvalidCron<OpenClawCronJobDetailDto>(
|
|
"wakeMode muss now oder next-heartbeat sein.");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static OpenClawOperationDto<OpenClawCronJobDetailDto>? ValidateCronPatch(
|
|
JsonObject patch)
|
|
{
|
|
if (patch is null || patch.Count == 0)
|
|
return InvalidCron<OpenClawCronJobDetailDto>("Der Zeitplan-Patch ist leer.");
|
|
|
|
var unsupported = patch
|
|
.Select(property => property.Key)
|
|
.Where(key => !AllowedCronPatchFields.Contains(key))
|
|
.Order(StringComparer.Ordinal)
|
|
.ToArray();
|
|
return unsupported.Length == 0
|
|
? null
|
|
: InvalidCron<OpenClawCronJobDetailDto>(
|
|
$"Nicht unterstützte Patch-Felder: {string.Join(", ", unsupported)}.");
|
|
}
|
|
|
|
private static OpenClawOperationDto<T> InvalidCron<T>(string message)
|
|
=> new(
|
|
false,
|
|
"invalid",
|
|
message,
|
|
default,
|
|
"Zeitplandaten korrigieren und erneut versuchen.",
|
|
DateTimeOffset.UtcNow);
|
|
|
|
private static OpenClawOperationDto<T> RestrictedCronOperation<T>()
|
|
=> new(
|
|
false,
|
|
"restricted",
|
|
"Command- und on-exit-Cronjobs sind in Nexus deaktiviert.",
|
|
default,
|
|
"Integrations:OpenClaw:AllowCommandCron nur nach Sicherheitsprüfung bewusst aktivieren.",
|
|
DateTimeOffset.UtcNow);
|
|
|
|
private static OpenClawOperationDto<T> CronNotFound<T>()
|
|
=> new(
|
|
false,
|
|
"not_found",
|
|
"OpenClaw-Zeitplan wurde nicht gefunden.",
|
|
default,
|
|
"Zeitplanliste aktualisieren und die Job-ID prüfen.",
|
|
DateTimeOffset.UtcNow);
|
|
|
|
private static OpenClawOperationDto<T> CronConflict<T>()
|
|
=> new(
|
|
false,
|
|
"conflict",
|
|
"Der OpenClaw-Zeitplan wurde zwischenzeitlich verändert.",
|
|
default,
|
|
"Aktuelle Details laden, Änderungen vergleichen und erneut bestätigen.",
|
|
DateTimeOffset.UtcNow);
|
|
|
|
private static JsonObject BuildCronCreateParameters(CreateOpenClawCronJobRequest request)
|
|
{
|
|
var parameters = new JsonObject
|
|
{
|
|
["name"] = request.Name.Trim(),
|
|
["enabled"] = request.Enabled,
|
|
["schedule"] = request.Schedule.DeepClone(),
|
|
["sessionTarget"] = request.SessionTarget.Trim(),
|
|
["wakeMode"] = request.WakeMode.Trim(),
|
|
["payload"] = request.Payload.DeepClone()
|
|
};
|
|
SetOptionalString(parameters, "description", request.Description);
|
|
SetOptionalString(parameters, "agentId", request.AgentId);
|
|
SetOptionalString(parameters, "sessionKey", request.SessionKey);
|
|
SetOptionalString(parameters, "declarationKey", request.DeclarationKey);
|
|
SetOptionalString(parameters, "displayName", request.DisplayName);
|
|
if (request.DeleteAfterRun is not null)
|
|
parameters["deleteAfterRun"] = request.DeleteAfterRun.Value;
|
|
if (request.Delivery is not null)
|
|
parameters["delivery"] = request.Delivery.DeepClone();
|
|
if (request.Trigger is not null)
|
|
parameters["trigger"] = request.Trigger.DeepClone();
|
|
if (request.FailureAlert is not null)
|
|
parameters["failureAlert"] = request.FailureAlert.DeepClone();
|
|
return parameters;
|
|
}
|
|
|
|
private static void SetOptionalString(JsonObject target, string name, string? value)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
target[name] = value.Trim();
|
|
}
|
|
|
|
private async Task<JsonNode?> ReadCronJobNodeAsync(
|
|
string jobId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (connector.Supports("cron.get"))
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"cron.get",
|
|
new { id = jobId },
|
|
cancellationToken: cancellationToken);
|
|
return (ReadNode(response, "job") ?? response)?.DeepClone();
|
|
}
|
|
|
|
if (!connector.Supports("cron.list"))
|
|
return null;
|
|
|
|
var list = await connector.InvokeAsync(
|
|
"cron.list",
|
|
new { includeDisabled = true, limit = 200, offset = 0 },
|
|
cancellationToken: cancellationToken);
|
|
return ReadItems(list, "jobs", "items")
|
|
.FirstOrDefault(node =>
|
|
string.Equals(ReadString(node, "id", "jobId"), jobId, StringComparison.Ordinal))
|
|
?.DeepClone();
|
|
}
|
|
|
|
private static bool MatchesExpectedHash(JsonNode current, string? expectedHash)
|
|
{
|
|
var normalized = NormalizeExpectedHash(expectedHash);
|
|
return normalized is null ||
|
|
string.Equals(
|
|
normalized,
|
|
ComputeCronResourceHash(current),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string? NormalizeExpectedHash(string? expectedHash)
|
|
{
|
|
var normalized = expectedHash?.Trim();
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
return null;
|
|
if (normalized.StartsWith("W/", StringComparison.OrdinalIgnoreCase))
|
|
normalized = normalized[2..].Trim();
|
|
return normalized.Trim('"');
|
|
}
|
|
|
|
private static bool IsRestrictedCronDefinition(JsonNode? node)
|
|
{
|
|
var schedule = ReadNode(node, "schedule") ?? node;
|
|
var payload = ReadNode(node, "payload") ?? node;
|
|
return string.Equals(
|
|
ReadString(schedule, "kind"),
|
|
"on-exit",
|
|
StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(
|
|
ReadString(payload, "kind"),
|
|
"command",
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool TryDecodeCursor(string? cursor, out int offset)
|
|
{
|
|
offset = 0;
|
|
if (string.IsNullOrWhiteSpace(cursor))
|
|
return true;
|
|
|
|
try
|
|
{
|
|
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursor.Trim()));
|
|
const string prefix = "cron-offset:";
|
|
return decoded.StartsWith(prefix, StringComparison.Ordinal) &&
|
|
int.TryParse(
|
|
decoded[prefix.Length..],
|
|
NumberStyles.None,
|
|
CultureInfo.InvariantCulture,
|
|
out offset) &&
|
|
offset >= 0;
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string EncodeCursor(int offset)
|
|
=> Convert.ToBase64String(Encoding.UTF8.GetBytes(
|
|
$"cron-offset:{offset.ToString(CultureInfo.InvariantCulture)}"));
|
|
|
|
private static string? ReadOffsetPaginationCursor(
|
|
JsonNode? response,
|
|
int currentOffset,
|
|
int requestedLimit,
|
|
params string[] collectionKeys)
|
|
{
|
|
var nextOffset = ReadInt(response, "nextOffset");
|
|
if (nextOffset is >= 0)
|
|
return EncodeCursor(nextOffset.Value);
|
|
|
|
var returnedCount = ReadItems(response, collectionKeys).Count;
|
|
if (returnedCount == 0 || ReadBool(response, "hasMore") == false)
|
|
return null;
|
|
|
|
var gatewayIndicatesMore =
|
|
ReadBool(response, "hasMore") == true ||
|
|
!string.IsNullOrWhiteSpace(ReadString(response, "nextCursor")) ||
|
|
returnedCount >= requestedLimit;
|
|
return gatewayIndicatesMore
|
|
? EncodeCursor(checked(currentOffset + returnedCount))
|
|
: null;
|
|
}
|
|
|
|
private static string ComputeCronResourceHash(JsonNode node)
|
|
{
|
|
var stable = node.DeepClone();
|
|
if (stable is JsonObject obj)
|
|
{
|
|
foreach (var property in new[]
|
|
{
|
|
"state",
|
|
"nextRunAtMs",
|
|
"lastRunAtMs",
|
|
"lastRunStatus",
|
|
"lastRunError",
|
|
"lastDelivered",
|
|
"lastDeliveryStatus",
|
|
"lastDeliveryError",
|
|
"lastFailureNotificationDelivered",
|
|
"lastFailureNotificationDeliveryStatus",
|
|
"lastFailureNotificationDeliveryError"
|
|
})
|
|
{
|
|
obj.Remove(property);
|
|
}
|
|
}
|
|
|
|
return OpenClawInvocationContextFactory.Hash(CanonicalJson(stable));
|
|
}
|
|
|
|
private static string CanonicalJson(JsonNode? node)
|
|
=> Canonicalize(node)?.ToJsonString() ?? "null";
|
|
|
|
private static JsonNode? Canonicalize(JsonNode? node)
|
|
{
|
|
return node switch
|
|
{
|
|
null => null,
|
|
JsonObject obj => new JsonObject(obj
|
|
.OrderBy(property => property.Key, StringComparer.Ordinal)
|
|
.Select(property => KeyValuePair.Create(
|
|
property.Key,
|
|
Canonicalize(property.Value)))),
|
|
JsonArray array => new JsonArray(array.Select(Canonicalize).ToArray()),
|
|
_ => node.DeepClone()
|
|
};
|
|
}
|
|
|
|
private bool HasScope(string scope)
|
|
{
|
|
var scopes = connector.GrantedScopes;
|
|
if (scopes.Contains("operator.admin"))
|
|
return true;
|
|
if (scopes.Contains(scope))
|
|
return true;
|
|
if (scope == "operator.read" && scopes.Contains("operator.write"))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
private static OpenClawCollectionDto<T> ReadyCollection<T>(IReadOnlyList<T> items)
|
|
=> new("ready", items, null, null, null, DateTimeOffset.UtcNow);
|
|
|
|
private static OpenClawCollectionDto<T> EmptyCollection<T>(
|
|
string state,
|
|
string? message,
|
|
string? recovery)
|
|
=> new(state, Array.Empty<T>(), null, message, recovery, DateTimeOffset.UtcNow);
|
|
|
|
private static OpenClawCollectionDto<T> CollectionFailure<T>(Exception exception)
|
|
{
|
|
var failure = DescribeFailure(exception);
|
|
return EmptyCollection<T>(failure.State, failure.Message, failure.Recovery);
|
|
}
|
|
|
|
private static OpenClawOperationDto<T> OperationFailure<T>(Exception exception)
|
|
{
|
|
var failure = DescribeFailure(exception);
|
|
return new OpenClawOperationDto<T>(
|
|
false,
|
|
failure.State,
|
|
failure.Message,
|
|
default,
|
|
failure.Recovery,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static OpenClawFailure DescribeFailure(Exception exception)
|
|
{
|
|
if (exception is not OpenClawGatewayRpcException gatewayException)
|
|
{
|
|
return new OpenClawFailure(
|
|
"error",
|
|
"OpenClaw-Anfrage ist fehlgeschlagen.",
|
|
"Nexus- und OpenClaw-Logs prüfen.");
|
|
}
|
|
|
|
var detailsCode = ReadString(gatewayException.Details, "code");
|
|
var recommended = ReadString(gatewayException.Details, "recommendedNextStep");
|
|
var code = gatewayException.Code.ToUpperInvariant();
|
|
|
|
if (code is "GATEWAY_DISCONNECTED" or "GATEWAY_TIMEOUT" or "UNAVAILABLE")
|
|
{
|
|
return new OpenClawFailure(
|
|
"disconnected",
|
|
code == "GATEWAY_TIMEOUT"
|
|
? "OpenClaw hat nicht rechtzeitig geantwortet."
|
|
: "OpenClaw Gateway ist nicht verbunden.",
|
|
"Gateway-Erreichbarkeit, BaseUrl und Dienststatus prüfen.");
|
|
}
|
|
|
|
if (code is "METHOD_UNAVAILABLE" or "METHOD_NOT_FOUND" or "NOT_IMPLEMENTED")
|
|
{
|
|
return new OpenClawFailure(
|
|
"unsupported",
|
|
"Die verbundene OpenClaw-Version unterstützt diese Aktion nicht.",
|
|
"OpenClaw-Version und Feature-Aushandlung prüfen.");
|
|
}
|
|
|
|
if (code is "FORBIDDEN" or "AUTH_SCOPE_MISMATCH" ||
|
|
string.Equals(detailsCode, "MISSING_SCOPE", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new OpenClawFailure(
|
|
"forbidden",
|
|
"OpenClaw hat Nexus nicht den benötigten Operator-Scope gewährt.",
|
|
"Gateway-Pairing beziehungsweise Nexus-Scopes prüfen.");
|
|
}
|
|
|
|
if (code.StartsWith("AUTH_", StringComparison.Ordinal) ||
|
|
code.StartsWith("DEVICE_AUTH_", StringComparison.Ordinal))
|
|
{
|
|
return new OpenClawFailure(
|
|
"forbidden",
|
|
"OpenClaw-Authentifizierung für Nexus ist fehlgeschlagen.",
|
|
MapRecommendedNextStep(recommended));
|
|
}
|
|
|
|
return new OpenClawFailure(
|
|
gatewayException.Retryable ? "degraded" : "error",
|
|
"OpenClaw hat die Anfrage abgelehnt.",
|
|
MapRecommendedNextStep(recommended));
|
|
}
|
|
|
|
private static string MapRecommendedNextStep(string? value)
|
|
{
|
|
return value switch
|
|
{
|
|
"retry_with_device_token" => "Nexus als Operator-Gerät neu koppeln und mit dem Device-Token erneut verbinden.",
|
|
"update_auth_configuration" => "OpenClaw-Authentifizierungsmodus und Nexus-Konfiguration abgleichen.",
|
|
"update_auth_credentials" => "OpenClaw-Token oder Passwort in der Laufzeitkonfiguration aktualisieren.",
|
|
"wait_then_retry" => "Pairing abschließen und die Verbindung anschließend erneut versuchen.",
|
|
"review_auth_configuration" => "Gateway-Auth, Loopback-Verbindung und Operator-Scopes prüfen.",
|
|
_ => "Nexus- und OpenClaw-Logs sowie die Gateway-Konfiguration prüfen."
|
|
};
|
|
}
|
|
|
|
private static string? BuildConnectionRecovery(
|
|
bool configured,
|
|
bool credentialConfigured,
|
|
bool connected,
|
|
string endpoint,
|
|
bool versionMatches)
|
|
{
|
|
if (!configured)
|
|
return "Integrations:OpenClaw:BaseUrl als absolute HTTP- oder HTTPS-Adresse konfigurieren.";
|
|
if (!credentialConfigured)
|
|
return "OpenClaw-Token oder Passwort ausschließlich in der Laufzeitkonfiguration hinterlegen.";
|
|
if (!versionMatches)
|
|
return "Integrations:OpenClaw:RequiredVersion bewusst an die installierte Gateway-Version anpassen.";
|
|
if (!connected && !IsLoopbackEndpoint(endpoint))
|
|
return "Remote Gateways benötigen eine gepaarte Device-Identity; für den backend helper OpenClaw direkt über Loopback anbinden.";
|
|
if (!connected)
|
|
return "OpenClaw Gateway starten und Token/Passwort sowie Port 18789 prüfen.";
|
|
return null;
|
|
}
|
|
|
|
private static string BuildPairingRecovery(string? requestId)
|
|
{
|
|
var normalizedRequestId = requestId?.Trim();
|
|
if (string.IsNullOrWhiteSpace(normalizedRequestId) ||
|
|
normalizedRequestId.Length > 128 ||
|
|
normalizedRequestId.Any(character =>
|
|
!(char.IsAsciiLetterOrDigit(character) ||
|
|
character is '-' or '_' or '.' or ':')))
|
|
{
|
|
return "OpenClaw-Geräteliste prüfen und den aktuellen Nexus-Pairing-Request freigeben.";
|
|
}
|
|
|
|
return $"Auf dem Gateway 'openclaw devices approve {normalizedRequestId}' nach Prüfung ausführen.";
|
|
}
|
|
|
|
private static string NormalizeEndpoint(string? value)
|
|
{
|
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
|
return string.IsNullOrWhiteSpace(value) ? "not configured" : "invalid";
|
|
|
|
var builder = new UriBuilder(uri)
|
|
{
|
|
UserName = string.Empty,
|
|
Password = string.Empty,
|
|
Query = string.Empty,
|
|
Fragment = string.Empty
|
|
};
|
|
return builder.Uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
|
}
|
|
|
|
private static bool IsLoopbackEndpoint(string endpoint)
|
|
{
|
|
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
|
return false;
|
|
return uri.IsLoopback ||
|
|
string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string? SafeConnectionMessage(string? message)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
return null;
|
|
|
|
var trimmed = message.Trim();
|
|
if (trimmed.Contains("token", StringComparison.OrdinalIgnoreCase) ||
|
|
trimmed.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
|
trimmed.Length > 240)
|
|
return "Gateway-Verbindungsdetails sind in den Backend-Logs verfügbar.";
|
|
return trimmed;
|
|
}
|
|
|
|
private OpenClawTaskDto MapTask(JsonNode? node)
|
|
{
|
|
var status = NormalizeTaskStatus(ReadString(node, "status", "state") ?? "unknown");
|
|
return new OpenClawTaskDto(
|
|
ReadString(node, "id", "taskId") ?? string.Empty,
|
|
ReadString(node, "title", "name", "summary") ?? "OpenClaw task",
|
|
status,
|
|
ReadString(node, "kind", "type"),
|
|
ReadString(node, "runtime"),
|
|
ReadString(node, "agentId", "agent"),
|
|
ReadString(node, "sessionKey"),
|
|
ReadString(node, "runId"),
|
|
ReadString(node, "flowId"),
|
|
ReadString(node, "parentTaskId"),
|
|
ReadDate(node, "createdAt", "createdAtMs", "queuedAt", "queuedAtMs"),
|
|
ReadDate(node, "startedAt", "startedAtMs"),
|
|
ReadDate(node, "updatedAt", "updatedAtMs"),
|
|
ReadDate(node, "finishedAt", "finishedAtMs", "completedAt", "completedAtMs"),
|
|
ReadDouble(node, "progress", "progressPercent"),
|
|
ReadString(node, "terminalSummary", "summary", "result"),
|
|
ReadString(node, "error", "errorText", "failure"),
|
|
connector.Supports("tasks.cancel") &&
|
|
HasScope("operator.write") &&
|
|
status is "queued" or "running");
|
|
}
|
|
|
|
private static OpenClawSessionDto MapSession(JsonNode? node, bool canAbortMethod)
|
|
{
|
|
var key = ReadString(node, "key", "sessionKey") ?? string.Empty;
|
|
var agentId = ReadString(node, "agentId", "agent") ?? ExtractAgentId(key) ?? "main";
|
|
var status = NormalizeSessionStatus(
|
|
ReadString(node, "status", "runStatus") ??
|
|
ReadString(ReadNode(node, "activeRun"), "status") ??
|
|
"idle");
|
|
var modelNode = ReadNode(node, "model", "resolvedModel");
|
|
var model = modelNode is JsonObject
|
|
? ReadString(modelNode, "id", "model")
|
|
: ReadString(node, "model");
|
|
var provider = ReadString(node, "provider") ??
|
|
ReadString(modelNode, "provider") ??
|
|
ExtractProvider(model);
|
|
var runId = ReadString(node, "runId") ??
|
|
ReadString(ReadNode(node, "activeRun"), "runId", "id");
|
|
|
|
return new OpenClawSessionDto(
|
|
key,
|
|
ReadString(node, "sessionId", "id"),
|
|
agentId,
|
|
ReadString(node, "title", "label", "displayName", "preview") ?? ShortSessionKey(key),
|
|
status,
|
|
ReadString(node, "kind", "chatType"),
|
|
ReadString(node, "channel", "channelId"),
|
|
model,
|
|
provider,
|
|
runId,
|
|
ReadDate(node, "updatedAt", "updatedAtMs", "lastInteractionAt", "lastInteractionAtMs"),
|
|
ReadLong(node, "inputTokens", "input_tokens"),
|
|
ReadLong(node, "outputTokens", "output_tokens"),
|
|
ReadLong(node, "totalTokens", "total_tokens"),
|
|
canAbortMethod && status is "running" or "active");
|
|
}
|
|
|
|
private OpenClawCronJobDto MapCronJob(JsonNode? node, bool canRunMethod)
|
|
{
|
|
var scheduleNode = ReadNode(node, "schedule");
|
|
var stateNode = ReadNode(node, "state");
|
|
var enabled = ReadBool(node, "enabled") ?? true;
|
|
var running = ReadDate(stateNode, "runningAtMs", "runningAt") is not null;
|
|
var lastStatus =
|
|
ReadString(stateNode, "lastRunStatus", "lastStatus") ??
|
|
ReadString(node, "lastRunStatus", "lastStatus");
|
|
var status = !enabled
|
|
? "disabled"
|
|
: running
|
|
? "running"
|
|
: lastStatus?.ToLowerInvariant() switch
|
|
{
|
|
"ok" => "ok",
|
|
"error" => "error",
|
|
"skipped" => "skipped",
|
|
_ => "idle"
|
|
};
|
|
|
|
return new OpenClawCronJobDto(
|
|
ReadString(node, "id", "jobId") ?? string.Empty,
|
|
ReadString(node, "name", "title") ?? "OpenClaw schedule",
|
|
ReadString(node, "description"),
|
|
FormatSchedule(scheduleNode),
|
|
ReadString(scheduleNode, "tz", "timezone"),
|
|
enabled,
|
|
status,
|
|
ReadString(node, "agentId"),
|
|
ReadString(node, "sessionKey"),
|
|
ReadDate(stateNode, "nextRunAtMs", "nextRunAt") ??
|
|
ReadDate(node, "nextRunAtMs", "nextRunAt"),
|
|
ReadDate(stateNode, "lastRunAtMs", "lastRunAt") ??
|
|
ReadDate(node, "lastRunAtMs", "lastRunAt"),
|
|
lastStatus,
|
|
SanitizeCronText(
|
|
ReadString(stateNode, "lastError", "lastDiagnosticSummary") ??
|
|
ReadString(node, "lastRunError", "lastError")),
|
|
canRunMethod &&
|
|
(AllowCommandCron || !IsRestrictedCronDefinition(node)),
|
|
node is null ? null : ComputeCronResourceHash(node));
|
|
}
|
|
|
|
private OpenClawCronJobDetailDto MapCronJobDetail(JsonNode node)
|
|
{
|
|
var schedule = ReadNode(node, "schedule");
|
|
var payload = ReadNode(node, "payload");
|
|
var delivery = ReadNode(node, "delivery");
|
|
var trigger = ReadNode(node, "trigger");
|
|
var failureAlert = ReadNode(node, "failureAlert");
|
|
var state = ReadNode(node, "state");
|
|
var canAdmin = CronManagementEnabled && HasScope("operator.admin");
|
|
var restricted = IsRestrictedCronDefinition(node) && !AllowCommandCron;
|
|
|
|
return new OpenClawCronJobDetailDto(
|
|
ReadString(node, "id", "jobId") ?? string.Empty,
|
|
ReadString(node, "name", "title") ?? "OpenClaw schedule",
|
|
ReadString(node, "displayName"),
|
|
ReadString(node, "description"),
|
|
ReadBool(node, "enabled") ?? true,
|
|
ReadBool(node, "deleteAfterRun") ?? false,
|
|
ReadString(node, "agentId"),
|
|
ReadString(node, "sessionKey"),
|
|
ReadString(node, "sessionTarget") ?? "isolated",
|
|
ReadString(node, "wakeMode") ?? "now",
|
|
MapCronSchedule(schedule),
|
|
MapCronPayload(payload),
|
|
delivery is null ? null : MapCronDelivery(delivery),
|
|
trigger is null
|
|
? null
|
|
: new OpenClawCronTriggerDto(
|
|
ReadString(trigger, "script") ?? string.Empty,
|
|
ReadBool(trigger, "once") ?? false),
|
|
failureAlert is null || failureAlert is JsonValue
|
|
? null
|
|
: new OpenClawCronFailureAlertDto(
|
|
ReadInt(failureAlert, "after"),
|
|
ReadString(failureAlert, "channel"),
|
|
ReadString(failureAlert, "to"),
|
|
ReadLong(failureAlert, "cooldownMs"),
|
|
ReadBool(failureAlert, "includeSkipped"),
|
|
ReadString(failureAlert, "mode"),
|
|
ReadString(failureAlert, "accountId")),
|
|
ReadDate(node, "createdAt", "createdAtMs"),
|
|
ReadDate(node, "updatedAt", "updatedAtMs"),
|
|
ReadDate(state, "nextRunAtMs", "nextRunAt") ??
|
|
ReadDate(node, "nextRunAtMs", "nextRunAt"),
|
|
ReadDate(state, "lastRunAtMs", "lastRunAt") ??
|
|
ReadDate(node, "lastRunAtMs", "lastRunAt"),
|
|
ReadString(state, "lastRunStatus", "lastStatus") ??
|
|
ReadString(node, "lastRunStatus", "lastStatus"),
|
|
SanitizeCronText(
|
|
ReadString(state, "lastError", "lastDiagnosticSummary") ??
|
|
ReadString(node, "lastRunError", "lastError")),
|
|
ComputeCronResourceHash(node),
|
|
canAdmin && connector.Supports("cron.update"),
|
|
canAdmin && connector.Supports("cron.remove"),
|
|
canAdmin && connector.Supports("cron.run") && !restricted);
|
|
}
|
|
|
|
private static OpenClawCronScheduleDto MapCronSchedule(JsonNode? node)
|
|
=> new(
|
|
ReadString(node, "kind") ?? "unknown",
|
|
ReadString(node, "expr"),
|
|
ReadString(node, "tz", "timezone"),
|
|
ReadString(node, "at"),
|
|
ReadLong(node, "everyMs"),
|
|
ReadLong(node, "anchorMs"),
|
|
ReadLong(node, "staggerMs"),
|
|
ReadString(node, "command"),
|
|
ReadString(node, "cwd"));
|
|
|
|
private static OpenClawCronPayloadDto MapCronPayload(JsonNode? node)
|
|
{
|
|
var environment = ReadNode(node, "env") as JsonObject;
|
|
return new OpenClawCronPayloadDto(
|
|
ReadString(node, "kind") ?? "unknown",
|
|
ReadString(node, "text"),
|
|
ReadString(node, "message"),
|
|
ReadString(node, "model"),
|
|
ReadStringArray(ReadNode(node, "fallbacks")),
|
|
ReadString(node, "thinking"),
|
|
ReadDouble(node, "timeoutSeconds"),
|
|
ReadBool(node, "allowUnsafeExternalContent"),
|
|
ReadBool(node, "lightContext"),
|
|
ReadStringArray(ReadNode(node, "toolsAllow")),
|
|
ReadStringArray(ReadNode(node, "argv")),
|
|
ReadString(node, "cwd"),
|
|
environment?.Select(property => property.Key)
|
|
.Order(StringComparer.Ordinal)
|
|
.ToArray() ?? Array.Empty<string>(),
|
|
ReadNode(node, "input") is not null,
|
|
ReadDouble(node, "noOutputTimeoutSeconds"),
|
|
ReadInt(node, "outputMaxBytes"));
|
|
}
|
|
|
|
private static OpenClawCronDeliveryDto MapCronDelivery(JsonNode node)
|
|
{
|
|
var completion = ReadNode(node, "completionDestination");
|
|
var failure = ReadNode(node, "failureDestination");
|
|
return new OpenClawCronDeliveryDto(
|
|
ReadString(node, "mode") ?? "none",
|
|
ReadString(node, "channel"),
|
|
ReadString(node, "to"),
|
|
ReadString(node, "threadId"),
|
|
ReadString(node, "accountId"),
|
|
ReadBool(node, "bestEffort"),
|
|
completion is null
|
|
? null
|
|
: new OpenClawCronDestinationDto(
|
|
ReadString(completion, "channel"),
|
|
ReadString(completion, "to"),
|
|
ReadString(completion, "accountId"),
|
|
ReadString(completion, "mode")),
|
|
failure is null
|
|
? null
|
|
: new OpenClawCronDestinationDto(
|
|
ReadString(failure, "channel"),
|
|
ReadString(failure, "to"),
|
|
ReadString(failure, "accountId"),
|
|
ReadString(failure, "mode")));
|
|
}
|
|
|
|
private static OpenClawCronRunDto MapCronRun(JsonNode? node)
|
|
{
|
|
var jobId = ReadString(node, "jobId", "id") ?? string.Empty;
|
|
var runId = ReadString(node, "runId");
|
|
var occurredAt = ReadDate(node, "ts", "timestamp", "occurredAt");
|
|
var usage = ReadNode(node, "usage");
|
|
var diagnostics = ReadNode(node, "diagnostics");
|
|
return new OpenClawCronRunDto(
|
|
runId ?? $"{jobId}:{occurredAt?.ToUnixTimeMilliseconds() ?? 0}",
|
|
jobId,
|
|
ReadString(node, "jobName"),
|
|
runId,
|
|
(ReadString(node, "status") ?? "unknown").ToLowerInvariant(),
|
|
ReadString(node, "action") ?? "finished",
|
|
SanitizeCronText(ReadString(node, "summary")),
|
|
SanitizeCronText(ReadString(node, "error")),
|
|
ReadString(node, "errorReason"),
|
|
ReadString(node, "deliveryStatus"),
|
|
SanitizeCronText(ReadString(node, "deliveryError")),
|
|
ReadBool(node, "delivered"),
|
|
ReadBool(node, "triggerFired"),
|
|
SanitizeCronText(ReadString(diagnostics, "summary")),
|
|
ReadItems(diagnostics, "entries")
|
|
.Select(MapCronRunDiagnostic)
|
|
.ToArray(),
|
|
ReadString(node, "sessionId"),
|
|
ReadString(node, "sessionKey"),
|
|
occurredAt,
|
|
ReadDate(node, "runAtMs", "runAt"),
|
|
ReadLong(node, "durationMs"),
|
|
ReadDate(node, "nextRunAtMs", "nextRunAt"),
|
|
ReadString(node, "model"),
|
|
ReadString(node, "provider"),
|
|
ReadLong(usage, "input_tokens", "inputTokens"),
|
|
ReadLong(usage, "output_tokens", "outputTokens"),
|
|
ReadLong(usage, "total_tokens", "totalTokens"));
|
|
}
|
|
|
|
private static OpenClawCronRunDiagnosticDto MapCronRunDiagnostic(JsonNode? node)
|
|
=> new(
|
|
ReadDate(node, "ts", "timestamp"),
|
|
ReadString(node, "source") ?? "cron",
|
|
ReadString(node, "severity") ?? "info",
|
|
SanitizeCronText(ReadString(node, "message")) ?? "Cron diagnostic",
|
|
ReadString(node, "toolName"),
|
|
ReadDouble(node, "exitCode"),
|
|
ReadBool(node, "truncated") ?? false);
|
|
|
|
private static string? SanitizeCronText(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return null;
|
|
var sanitized = DeliveryTargetPattern.Replace(value.Trim(), "[redacted-target]");
|
|
return sanitized.Length <= 1000 ? sanitized : $"{sanitized[..997]}...";
|
|
}
|
|
|
|
private static OpenClawActivityDto MapActivity(JsonNode? node)
|
|
{
|
|
var eventType = ReadString(node, "eventType", "type") ?? "activity";
|
|
var action = ReadString(node, "action") ?? eventType;
|
|
var status = ReadString(node, "status", "outcome") ?? "recorded";
|
|
var id = ReadString(node, "eventId", "id") ??
|
|
$"{eventType}:{ReadLong(node, "sequence") ?? 0}";
|
|
var message = ReadString(node, "message", "summary", "description") ??
|
|
$"{action} · {status}";
|
|
|
|
return new OpenClawActivityDto(
|
|
id,
|
|
eventType,
|
|
ReadString(node, "kind") ?? "gateway",
|
|
action,
|
|
status,
|
|
message,
|
|
ReadString(node, "severity"),
|
|
ReadString(node, "actor", "actorId"),
|
|
ReadString(node, "agentId"),
|
|
ReadString(node, "sessionKey"),
|
|
ReadString(node, "runId"),
|
|
ReadDate(node, "occurredAt", "occurredAtMs", "timestamp", "ts"),
|
|
"openclaw-audit");
|
|
}
|
|
|
|
private static OpenClawActivityDto MapGatewayEvent(GatewayEventEnvelope envelope)
|
|
{
|
|
var payload = envelope.Payload;
|
|
var status = ReadString(payload, "status", "state") ?? "event";
|
|
return new OpenClawActivityDto(
|
|
$"{envelope.Event}:{envelope.Sequence?.ToString(CultureInfo.InvariantCulture) ?? envelope.ReceivedAt.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)}",
|
|
envelope.Event,
|
|
ReadString(payload, "kind") ?? "gateway-event",
|
|
ReadString(payload, "action") ?? envelope.Event,
|
|
status,
|
|
ReadString(payload, "message", "summary") ?? $"Gateway event: {envelope.Event}",
|
|
ReadString(payload, "severity"),
|
|
ReadString(payload, "actor", "actorId"),
|
|
ReadString(payload, "agentId"),
|
|
ReadString(payload, "sessionKey"),
|
|
ReadString(payload, "runId"),
|
|
envelope.ReceivedAt,
|
|
"openclaw-event-stream");
|
|
}
|
|
|
|
private static OpenClawApprovalDto MapApproval(JsonNode? node, bool canResolve)
|
|
{
|
|
var kind = ReadString(node, "kind", "approvalKind", "type") ?? "exec";
|
|
var id = ReadString(node, "id", "approvalId", "requestId") ?? string.Empty;
|
|
var status = ReadString(node, "status", "state") ?? "pending";
|
|
var allowed = ReadStringArray(ReadNode(node, "allowedDecisions"));
|
|
if (allowed.Count == 0)
|
|
allowed = ApprovalDecisions;
|
|
|
|
return new OpenClawApprovalDto(
|
|
id,
|
|
kind,
|
|
ReadString(node, "title", "command", "toolName") ?? "OpenClaw approval",
|
|
ReadString(node, "description", "reason", "message"),
|
|
status.ToLowerInvariant(),
|
|
(ReadString(node, "severity") ?? "warning").ToLowerInvariant(),
|
|
ReadString(node, "command", "rawCommand", "commandText"),
|
|
ReadString(node, "cwd", "workingDirectory"),
|
|
ReadString(node, "agentId"),
|
|
ReadString(node, "sessionKey"),
|
|
ReadDate(node, "requestedAt", "requestedAtMs", "createdAt", "createdAtMs"),
|
|
ReadDate(node, "expiresAt", "expiresAtMs"),
|
|
allowed,
|
|
canResolve && string.Equals(status, "pending", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static OpenClawModelDto MapModel(JsonNode? node)
|
|
{
|
|
var id = ReadString(node, "id", "model", "modelId") ?? string.Empty;
|
|
var provider = ReadString(node, "provider", "providerId") ?? ExtractProvider(id) ?? "unknown";
|
|
var configured = ReadBool(node, "configured", "isConfigured") ?? true;
|
|
var available = ReadBool(node, "available", "eligible", "isAvailable") ?? configured;
|
|
return new OpenClawModelDto(
|
|
id,
|
|
ReadString(node, "name", "label", "displayName") ?? id,
|
|
provider,
|
|
configured,
|
|
available,
|
|
ReadInt(node, "contextWindow", "contextLength", "maxTokens"),
|
|
ReadString(node, "reason", "unavailableReason"));
|
|
}
|
|
|
|
private static OpenClawModelAuthProviderDto MapModelAuthProvider(JsonNode? node)
|
|
{
|
|
var provider = SafeModelAuthText(
|
|
ReadString(node, "provider"),
|
|
fallback: "unknown",
|
|
maxLength: 96);
|
|
var displayName = SafeModelAuthText(
|
|
ReadString(node, "displayName"),
|
|
fallback: provider,
|
|
maxLength: 120);
|
|
var status = NormalizeModelAuthStatus(ReadString(node, "status"));
|
|
var profiles = ReadItems(node, "profiles")
|
|
.Select(profile => new
|
|
{
|
|
Type = NormalizeModelAuthProfileType(ReadString(profile, "type")),
|
|
Status = NormalizeModelAuthStatus(ReadString(profile, "status"))
|
|
})
|
|
.GroupBy(profile => (profile.Type, profile.Status))
|
|
.Select(group => new OpenClawModelAuthProfileSummaryDto(
|
|
group.Key.Type,
|
|
group.Key.Status,
|
|
group.Count()))
|
|
.OrderBy(profile => profile.Type, StringComparer.Ordinal)
|
|
.ThenBy(profile => profile.Status, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
return new OpenClawModelAuthProviderDto(
|
|
provider,
|
|
displayName,
|
|
status,
|
|
MapModelAuthExpiry(ReadNode(node, "expiry")),
|
|
profiles,
|
|
MapModelAuthApiKey(ReadNode(node, "apiKey")),
|
|
MapModelAuthUsage(ReadNode(node, "usage")));
|
|
}
|
|
|
|
private static OpenClawModelAuthExpiryDto? MapModelAuthExpiry(JsonNode? node)
|
|
{
|
|
var at = ReadDate(node, "at");
|
|
var remainingMs = ReadLong(node, "remainingMs");
|
|
if (at is null || remainingMs is null)
|
|
return null;
|
|
|
|
return new OpenClawModelAuthExpiryDto(
|
|
at.Value,
|
|
remainingMs.Value,
|
|
FormatModelAuthRemaining(remainingMs.Value));
|
|
}
|
|
|
|
private static OpenClawModelAuthApiKeyDto? MapModelAuthApiKey(JsonNode? node)
|
|
{
|
|
var source = ReadString(node, "source")?.ToLowerInvariant();
|
|
if (source is not ("config" or "env"))
|
|
return null;
|
|
|
|
var envVar = source == "env" ? ReadString(node, "envVar") : null;
|
|
if (envVar is not null && !SafeEnvironmentVariablePattern.IsMatch(envVar))
|
|
envVar = null;
|
|
|
|
return new OpenClawModelAuthApiKeyDto(source, envVar);
|
|
}
|
|
|
|
private static OpenClawModelAuthUsageDto? MapModelAuthUsage(JsonNode? node)
|
|
{
|
|
var summary = SafeUsageText(ReadString(node, "summary"), 160);
|
|
var plan = SafeUsageText(ReadString(node, "plan"), 80);
|
|
return summary is null && plan is null
|
|
? null
|
|
: new OpenClawModelAuthUsageDto(summary, plan);
|
|
}
|
|
|
|
private static string NormalizeModelAuthStatus(string? value)
|
|
{
|
|
var normalized = value?.Trim().ToLowerInvariant();
|
|
return normalized is not null && ModelAuthStatuses.Contains(normalized)
|
|
? normalized
|
|
: "unknown";
|
|
}
|
|
|
|
private static string NormalizeModelAuthProfileType(string? value)
|
|
{
|
|
var normalized = value?.Trim().ToLowerInvariant();
|
|
return normalized is not null && ModelAuthProfileTypes.Contains(normalized)
|
|
? normalized
|
|
: "unknown";
|
|
}
|
|
|
|
private static string SafeModelAuthText(
|
|
string? value,
|
|
string fallback,
|
|
int maxLength)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return fallback;
|
|
|
|
var safe = new string(value
|
|
.Where(character => !char.IsControl(character))
|
|
.ToArray())
|
|
.Trim();
|
|
if (safe.Length == 0)
|
|
return fallback;
|
|
return safe.Length <= maxLength ? safe : safe[..maxLength];
|
|
}
|
|
|
|
private static string? SafeUsageText(string? value, int maxLength)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return null;
|
|
|
|
var safe = string.Join(
|
|
" ",
|
|
value.Split(
|
|
(char[]?)null,
|
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
|
if (safe.Length == 0 || SensitiveUsageTextPattern.IsMatch(safe))
|
|
return null;
|
|
return safe.Length <= maxLength ? safe : safe[..maxLength];
|
|
}
|
|
|
|
private static string FormatModelAuthRemaining(long remainingMs)
|
|
{
|
|
var expired = remainingMs < 0;
|
|
var absoluteMs = Math.Abs((double)remainingMs);
|
|
var totalDays = absoluteMs / 86_400_000d;
|
|
var totalHours = absoluteMs / 3_600_000d;
|
|
var totalMinutes = absoluteMs / 60_000d;
|
|
var value = totalDays >= 1
|
|
? $"{Math.Floor(totalDays):0}d"
|
|
: totalHours >= 1
|
|
? $"{Math.Floor(totalHours):0}h"
|
|
: $"{Math.Max(0, Math.Floor(totalMinutes)):0}m";
|
|
return expired ? $"-{value}" : value;
|
|
}
|
|
|
|
private static OpenClawAgentDto MapAgent(JsonNode? node)
|
|
{
|
|
var id = ReadString(node, "id", "agentId") ?? string.Empty;
|
|
var modelNode = ReadNode(node, "model", "effectiveModel");
|
|
var model = modelNode is JsonObject
|
|
? ReadString(modelNode, "id", "model")
|
|
: ReadString(node, "model");
|
|
return new OpenClawAgentDto(
|
|
id,
|
|
ReadString(node, "name", "label", "displayName") ?? id,
|
|
ReadString(node, "description", "role"),
|
|
model,
|
|
ReadString(node, "provider") ??
|
|
ReadString(modelNode, "provider") ??
|
|
ExtractProvider(model),
|
|
ReadString(node, "workspace", "workspaceDir", "cwd"),
|
|
(ReadString(node, "status", "state") ?? "configured").ToLowerInvariant());
|
|
}
|
|
|
|
private static string FormatSchedule(JsonNode? schedule)
|
|
{
|
|
var kind = ReadString(schedule, "kind");
|
|
return kind switch
|
|
{
|
|
"cron" => ReadString(schedule, "expr") ?? "cron",
|
|
"every" => FormatEvery(ReadLong(schedule, "everyMs")),
|
|
"at" => ReadString(schedule, "at") ?? "one-time",
|
|
"on-exit" => "on-exit",
|
|
_ => ReadString(schedule, "expr", "value") ?? "unknown"
|
|
};
|
|
}
|
|
|
|
private static string FormatEvery(long? milliseconds)
|
|
{
|
|
if (milliseconds is null)
|
|
return "interval";
|
|
var duration = TimeSpan.FromMilliseconds(milliseconds.Value);
|
|
if (duration.TotalDays >= 1 && duration.TotalDays % 1 == 0)
|
|
return $"every {duration.TotalDays:0}d";
|
|
if (duration.TotalHours >= 1 && duration.TotalHours % 1 == 0)
|
|
return $"every {duration.TotalHours:0}h";
|
|
if (duration.TotalMinutes >= 1 && duration.TotalMinutes % 1 == 0)
|
|
return $"every {duration.TotalMinutes:0}m";
|
|
return $"every {duration.TotalSeconds:0}s";
|
|
}
|
|
|
|
private static string NormalizeTaskStatus(string status)
|
|
{
|
|
return status.Trim().ToLowerInvariant() switch
|
|
{
|
|
"completed" or "complete" or "success" or "ok" => "succeeded",
|
|
"canceled" => "cancelled",
|
|
"timedout" or "timeout" => "timed_out",
|
|
var value => value
|
|
};
|
|
}
|
|
|
|
private static string NormalizeSessionStatus(string status)
|
|
{
|
|
return status.Trim().ToLowerInvariant() switch
|
|
{
|
|
"in_progress" or "in-progress" => "running",
|
|
"completed" or "succeeded" or "failed" or "cancelled" => "idle",
|
|
var value => value
|
|
};
|
|
}
|
|
|
|
private static string? ExtractAgentId(string sessionKey)
|
|
{
|
|
var parts = sessionKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
|
var index = Array.FindIndex(parts, part =>
|
|
string.Equals(part, "agent", StringComparison.OrdinalIgnoreCase));
|
|
return index >= 0 && index + 1 < parts.Length ? parts[index + 1] : null;
|
|
}
|
|
|
|
private static string ShortSessionKey(string key)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
return "OpenClaw session";
|
|
return key.Length <= 56 ? key : $"…{key[^55..]}";
|
|
}
|
|
|
|
private static string? ExtractProvider(string? model)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(model))
|
|
return null;
|
|
var slash = model.IndexOf('/');
|
|
return slash > 0 ? model[..slash] : null;
|
|
}
|
|
|
|
private static IReadOnlyList<JsonNode> ReadItems(JsonNode? response, params string[] keys)
|
|
{
|
|
if (response is JsonArray direct)
|
|
return direct.Where(item => item is not null).Select(item => item!).ToArray();
|
|
|
|
foreach (var key in keys)
|
|
{
|
|
if (ReadNode(response, key) is JsonArray array)
|
|
return array.Where(item => item is not null).Select(item => item!).ToArray();
|
|
}
|
|
|
|
return Array.Empty<JsonNode>();
|
|
}
|
|
|
|
private static JsonNode? ReadNode(JsonNode? node, params string[] keys)
|
|
{
|
|
if (node is not JsonObject obj)
|
|
return null;
|
|
|
|
foreach (var key in keys)
|
|
{
|
|
var property = obj.FirstOrDefault(item =>
|
|
string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
|
|
if (property.Value is not null)
|
|
return property.Value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? ReadString(JsonNode? node, params string[] keys)
|
|
{
|
|
var value = keys.Length == 0 ? node : ReadNode(node, keys);
|
|
if (value is not JsonValue jsonValue)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
if (jsonValue.TryGetValue<string>(out var text))
|
|
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
|
return jsonValue.ToJsonString().Trim('"');
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static bool? ReadBool(JsonNode? node, params string[] keys)
|
|
{
|
|
var value = ReadNode(node, keys);
|
|
if (value is not JsonValue jsonValue)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
if (jsonValue.TryGetValue<bool>(out var result))
|
|
return result;
|
|
if (jsonValue.TryGetValue<string>(out var text) && bool.TryParse(text, out result))
|
|
return result;
|
|
}
|
|
catch
|
|
{
|
|
// Ignore incompatible Gateway values and keep the stable facade nullable.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static int? ReadInt(JsonNode? node, params string[] keys)
|
|
{
|
|
var number = ReadLong(node, keys);
|
|
return number is >= int.MinValue and <= int.MaxValue ? (int)number.Value : null;
|
|
}
|
|
|
|
private static long? ReadLong(JsonNode? node, params string[] keys)
|
|
{
|
|
var value = ReadNode(node, keys);
|
|
if (value is not JsonValue jsonValue)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
if (jsonValue.TryGetValue<long>(out var number))
|
|
return number;
|
|
if (jsonValue.TryGetValue<double>(out var doubleNumber))
|
|
return Convert.ToInt64(doubleNumber);
|
|
if (jsonValue.TryGetValue<string>(out var text) &&
|
|
long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out number))
|
|
return number;
|
|
}
|
|
catch
|
|
{
|
|
// Ignore incompatible Gateway values and keep the stable facade nullable.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static double? ReadDouble(JsonNode? node, params string[] keys)
|
|
{
|
|
var value = ReadNode(node, keys);
|
|
if (value is not JsonValue jsonValue)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
if (jsonValue.TryGetValue<double>(out var number))
|
|
return number;
|
|
if (jsonValue.TryGetValue<string>(out var text) &&
|
|
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number))
|
|
return number;
|
|
}
|
|
catch
|
|
{
|
|
// Ignore incompatible Gateway values and keep the stable facade nullable.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static DateTimeOffset? ReadDate(JsonNode? node, params string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
var value = ReadNode(node, key);
|
|
if (value is not JsonValue jsonValue)
|
|
continue;
|
|
|
|
try
|
|
{
|
|
if (jsonValue.TryGetValue<long>(out var numeric))
|
|
{
|
|
return numeric > 10_000_000_000
|
|
? DateTimeOffset.FromUnixTimeMilliseconds(numeric)
|
|
: DateTimeOffset.FromUnixTimeSeconds(numeric);
|
|
}
|
|
|
|
if (jsonValue.TryGetValue<double>(out var doubleNumeric))
|
|
return DateTimeOffset.FromUnixTimeMilliseconds(Convert.ToInt64(doubleNumeric));
|
|
|
|
if (jsonValue.TryGetValue<string>(out var text))
|
|
{
|
|
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out numeric))
|
|
{
|
|
return numeric > 10_000_000_000
|
|
? DateTimeOffset.FromUnixTimeMilliseconds(numeric)
|
|
: DateTimeOffset.FromUnixTimeSeconds(numeric);
|
|
}
|
|
|
|
if (DateTimeOffset.TryParse(
|
|
text,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal,
|
|
out var parsed))
|
|
return parsed;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore malformed dates from newer or plugin-owned payloads.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IReadOnlyList<string> ReadStringArray(JsonNode? node)
|
|
{
|
|
if (node is not JsonArray array)
|
|
return Array.Empty<string>();
|
|
return array
|
|
.Select(item => ReadString(item))
|
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
|
.Select(item => item!)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
}
|
|
|
|
private sealed record OpenClawCapabilityDefinition(
|
|
string Id,
|
|
string Label,
|
|
string Method,
|
|
string RequiredScope);
|
|
|
|
private sealed record OpenClawFailure(
|
|
string State,
|
|
string Message,
|
|
string Recovery);
|
|
}
|