using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Nexus.Api.Models; namespace Nexus.Api.Services; public sealed class OpenClawEventProjectionService(IGatewayConnector connector) : IOpenClawEventProjectionService { public OpenClawEventBatch Project(string? lastEventId, int limit = 500) { var projectedAt = DateTimeOffset.UtcNow; var requestedCursor = NormalizeCursor(lastEventId); var envelopes = connector .GetRecentEvents(Math.Clamp(limit, 1, 500)) .OrderBy(item => item.ReceivedAt) .ThenBy(item => item.Sequence) .ThenBy(OpenClawEventIdentity.Create, StringComparer.Ordinal) .ToList(); var allEvents = ProjectEvents(envelopes); var oldestId = allEvents.FirstOrDefault()?.Id; var latestId = allEvents.LastOrDefault()?.Id; var replayBoundaryMissed = false; IReadOnlyList selected = allEvents; if (requestedCursor is not null) { var cursorIndex = allEvents.FindIndex(item => string.Equals(item.Id, requestedCursor, StringComparison.Ordinal)); if (cursorIndex >= 0) { selected = allEvents.Skip(cursorIndex + 1).ToList(); } else { replayBoundaryMissed = true; } } var cursor = selected.LastOrDefault()?.Id ?? (replayBoundaryMissed ? latestId ?? "origin" : requestedCursor); return new OpenClawEventBatch( selected, cursor, replayBoundaryMissed, oldestId, latestId, projectedAt); } public OpenClawStreamEventDto CreateConnectionEvent(string? cursor) { var occurredAt = DateTimeOffset.UtcNow; return new OpenClawStreamEventDto( NormalizeCursor(cursor) ?? "origin", "openclaw.connection", "connection", "connection", null, null, null, false, false, null, null, occurredAt, new JsonObject { ["state"] = connector.ConnectionState.ToString().ToLowerInvariant(), ["connected"] = connector.ConnectionState == GatewayConnectionState.Connected, ["gatewayVersion"] = connector.GatewayVersion, ["protocolVersion"] = connector.ProtocolVersion, ["deviceId"] = connector.DeviceId, ["deviceTokenConfigured"] = connector.DeviceTokenConfigured, ["pairingRequired"] = connector.PairingRequired, ["pairingRequestId"] = connector.PairingRequestId, ["lastConnectedAt"] = connector.LastConnectedAt, ["lastEventAt"] = connector.LastEventAt, ["reconnectAttempts"] = connector.ReconnectAttempts, ["message"] = connector.StatusMessage }); } public OpenClawStreamEventDto CreateHeartbeatEvent(string? cursor) { var occurredAt = DateTimeOffset.UtcNow; return new OpenClawStreamEventDto( NormalizeCursor(cursor) ?? "origin", "openclaw.heartbeat", "heartbeat", "heartbeat", null, null, null, false, false, null, null, occurredAt, new JsonObject { ["connected"] = connector.ConnectionState == GatewayConnectionState.Connected, ["lastEventAt"] = connector.LastEventAt, ["sentAt"] = occurredAt }); } private static List ProjectEvents( IReadOnlyList envelopes) { var result = new List(envelopes.Count); long? previousSequence = null; foreach (var envelope in envelopes) { var sequenceGap = envelope.Sequence.HasValue && previousSequence.HasValue && envelope.Sequence.Value > previousSequence.Value + 1; var sequenceReset = envelope.Sequence.HasValue && previousSequence.HasValue && envelope.Sequence.Value < previousSequence.Value; var missingFrom = sequenceGap ? previousSequence + 1 : null; var missingTo = sequenceGap ? envelope.Sequence - 1 : null; var category = Classify(envelope.Event, envelope.Payload); result.Add(new OpenClawStreamEventDto( OpenClawEventIdentity.Create(envelope), $"openclaw.{category}", envelope.Event, category, envelope.Sequence, envelope.StateVersion, previousSequence, sequenceGap, sequenceReset, missingFrom, missingTo, envelope.ReceivedAt, OpenClawPayloadSanitizer.Redact(envelope.Payload))); if (envelope.Sequence.HasValue) previousSequence = envelope.Sequence; } return result; } private static string Classify(string eventName, JsonNode? payload) { var normalized = eventName.ToLowerInvariant(); if (normalized.Contains("approval", StringComparison.Ordinal)) return "approval"; if (normalized.Contains("artifact", StringComparison.Ordinal)) return "artifact"; if (normalized.Contains("tool", StringComparison.Ordinal)) return "tool"; if (normalized.Contains("session", StringComparison.Ordinal)) return "session"; if (normalized is "chat" or "agent" || normalized.Contains("run", StringComparison.Ordinal) || payload?["runId"] is not null) { return "run"; } return "gateway"; } private static string? NormalizeCursor(string? cursor) { var trimmed = cursor?.Trim(); return string.IsNullOrWhiteSpace(trimmed) || string.Equals(trimmed, "origin", StringComparison.Ordinal) ? null : trimmed; } } internal static class OpenClawEventIdentity { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public static string Create(GatewayEventEnvelope envelope) { var payload = envelope.Payload?.ToJsonString(JsonOptions) ?? "null"; var fingerprint = string.Join( "\u001f", envelope.Event, envelope.ReceivedAt.UtcDateTime.Ticks, envelope.Sequence?.ToString() ?? string.Empty, envelope.StateVersion?.ToString() ?? string.Empty, payload); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(fingerprint)); return $"gw-{envelope.ReceivedAt.UtcDateTime.Ticks:x16}-{Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant()}"; } }