using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nexus.Api.Models; using Nexus.Api.Services; namespace Nexus.Api.Controllers; /// /// Authenticated browser-safe Server-Sent Events projection over the bounded /// Gateway event buffer. The browser never receives Gateway credentials. /// [Authorize] [ApiController] [Route("api/v1/openclaw/events")] public sealed class OpenClawEventsController(IOpenClawEventProjectionService events) : ControllerBase { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(750); private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15); [HttpGet] public async Task Stream( [FromQuery] string? lastEventId = null, [FromQuery] bool follow = true, CancellationToken cancellationToken = default) { Response.StatusCode = StatusCodes.Status200OK; Response.ContentType = "text/event-stream"; Response.Headers.CacheControl = "no-cache, no-store"; Response.Headers.Connection = "keep-alive"; Response.Headers["X-Accel-Buffering"] = "no"; var headerCursor = Request.Headers["Last-Event-ID"].FirstOrDefault(); var cursor = string.IsNullOrWhiteSpace(headerCursor) ? lastEventId : headerCursor; var lastHeartbeatAt = DateTimeOffset.UtcNow; var connectionEvent = events.CreateConnectionEvent(cursor); var connectionSignature = GetConnectionSignature(connectionEvent); await Response.WriteAsync("retry: 2000\n\n", cancellationToken); await WriteEventAsync(connectionEvent, cancellationToken); await Response.Body.FlushAsync(cancellationToken); try { do { connectionEvent = events.CreateConnectionEvent(cursor); var currentConnectionSignature = GetConnectionSignature(connectionEvent); if (!string.Equals( connectionSignature, currentConnectionSignature, StringComparison.Ordinal)) { await WriteEventAsync(connectionEvent, cancellationToken); connectionSignature = currentConnectionSignature; } var batch = events.Project(cursor); if (batch.ReplayBoundaryMissed) { await WriteEventAsync(CreateGapEvent(cursor, batch), cancellationToken); } foreach (var item in batch.Events) { await WriteEventAsync(item, cancellationToken); } cursor = batch.Cursor ?? cursor; var now = DateTimeOffset.UtcNow; if (!follow || now - lastHeartbeatAt >= HeartbeatInterval) { await WriteEventAsync(events.CreateHeartbeatEvent(cursor), cancellationToken); lastHeartbeatAt = now; } await Response.Body.FlushAsync(cancellationToken); if (!follow) break; await Task.Delay(PollInterval, cancellationToken); } while (!cancellationToken.IsCancellationRequested); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // Expected when EventSource disconnects or the request is aborted. } } private async Task WriteEventAsync( OpenClawStreamEventDto item, CancellationToken cancellationToken) { var id = StripSseControlCharacters(item.Id); var eventType = StripSseControlCharacters(item.Type); var data = JsonSerializer.Serialize(item, JsonOptions); await Response.WriteAsync( $"id: {id}\nevent: {eventType}\ndata: {data}\n\n", cancellationToken); } private static OpenClawStreamEventDto CreateGapEvent( string? requestedCursor, OpenClawEventBatch batch) { var occurredAt = DateTimeOffset.UtcNow; var gapCursor = batch.Events.Count == 0 ? batch.Cursor ?? "origin" : string.IsNullOrWhiteSpace(requestedCursor) ? "origin" : requestedCursor; return new OpenClawStreamEventDto( gapCursor, "openclaw.gap", "gap", "gateway", null, null, null, true, false, null, null, occurredAt, new JsonObject { ["reason"] = "last-event-id-outside-buffer", ["requestedLastEventId"] = requestedCursor, ["oldestAvailableId"] = batch.OldestAvailableId, ["latestAvailableId"] = batch.LatestAvailableId, ["requiresAuthoritativeRefresh"] = true }); } private static string StripSseControlCharacters(string value) => value.Replace("\r", string.Empty, StringComparison.Ordinal) .Replace("\n", string.Empty, StringComparison.Ordinal); private static string GetConnectionSignature(OpenClawStreamEventDto item) => string.Join( "\u001f", item.Payload?["state"]?.ToJsonString() ?? string.Empty, item.Payload?["connected"]?.ToJsonString() ?? string.Empty, item.Payload?["gatewayVersion"]?.ToJsonString() ?? string.Empty, item.Payload?["protocolVersion"]?.ToJsonString() ?? string.Empty, item.Payload?["deviceId"]?.ToJsonString() ?? string.Empty, item.Payload?["deviceTokenConfigured"]?.ToJsonString() ?? string.Empty, item.Payload?["pairingRequired"]?.ToJsonString() ?? string.Empty, item.Payload?["pairingRequestId"]?.ToJsonString() ?? string.Empty, item.Payload?["lastConnectedAt"]?.ToJsonString() ?? string.Empty, item.Payload?["reconnectAttempts"]?.ToJsonString() ?? string.Empty, item.Payload?["message"]?.ToJsonString() ?? string.Empty); }