using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; using Nexus.Api.Models; namespace Nexus.Api.Services; public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private string? GetPassword() { var password = configuration["Integrations:OpenClaw:Password"]; if (string.IsNullOrWhiteSpace(password)) password = configuration["Integrations:OpenClaw:Token"]; return string.IsNullOrWhiteSpace(password) ? null : password; } private void ApplyAuth(HttpRequestMessage request) { var password = GetPassword(); if (password is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", password); } public async Task InvokeToolAsync(string tool, object? args = null) { try { using var request = new HttpRequestMessage(HttpMethod.Post, "/tools/invoke"); ApplyAuth(request); var body = new Dictionary { ["tool"] = tool }; if (args is not null) body["args"] = args; request.Content = JsonContent.Create(body); using var response = await httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(json)) return null; var node = JsonNode.Parse(json); // Unwrap the { ok: true, result: ... } envelope if (node?["ok"]?.GetValue() == true && node["result"] is not null) return node["result"]; return node; } catch { return null; } } /// /// Extracts useful data from a tool response that may embed JSON in content[0].text. /// Returns the unwrapped "details" object if present, otherwise the raw result. /// private static JsonNode? ExtractToolData(JsonNode? result) { if (result is null) return null; // Some tools return { details: {...}, content: [...] } if (result["details"] is JsonNode details && details is not JsonValue) return details; // Some tools wrap in content[0].text as JSON string if (result["content"] is JsonArray content && content.Count > 0) { var text = content[0]?["text"]?.GetValue(); if (!string.IsNullOrWhiteSpace(text)) { try { return JsonNode.Parse(text); } catch { /* fall through */ } } } return result; } public async Task> GetAgentsAsync() { // Hardcoded agent list (known from OpenClaw config). // Tools/invoke visibility is restricted to agent:main scope, // so we can't enumerate Iris sessions programmatically. var agentDefs = new[] { new { Id = "iris", Name = "Iris", Model = "GPT-5.4" }, new { Id = "programmer", Name = "Programmer", Model = "DeepSeek V4 Flash" }, new { Id = "reviewer", Name = "Reviewer", Model = "DeepSeek V4 Pro" }, new { Id = "architekt", Name = "DevOps", Model = "DeepSeek V4 Pro" }, new { Id = "executor", Name = "Executor", Model = "DeepSeek V4 Flash" }, new { Id = "researcher", Name = "Researcher", Model = "DeepSeek V4 Pro" }, }; var agents = new List(); foreach (var def in agentDefs) { var isActive = false; string? currentTask = null; // Check workspace activity via file timestamps var memDir = $"/mnt/workspace-{def.Id}/memory"; try { if (Directory.Exists(memDir)) { var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories) .Select(f => new FileInfo(f)) .OrderByDescending(f => f.LastWriteTimeUtc) .FirstOrDefault(); if (latestFile is not null) { var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc; isActive = age.TotalMinutes < 15; if (isActive) currentTask = $"Modified: {latestFile.Name}"; } } } catch { /* workspace not mounted or inaccessible */ } agents.Add(new DashboardAgentInfo( Id: def.Id, Name: def.Name, Role: DeriveRole(def.Id), Model: def.Model, IsActive: isActive, CurrentTask: currentTask )); } return agents; } public async Task> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0) { try { var result = await InvokeToolAsync("sessions_history", new { sessionKey, limit, offset, includeTools = false }); if (result is null) return new List(); // sessions_history returns { details: { messages: [...] } } var messageArray = result["details"]?["messages"] as JsonArray; if (messageArray is null) return new List(); var messages = new List(); foreach (var msg in messageArray.Cast()) { if (msg is null) continue; var role = msg["role"]?.GetValue() ?? ""; // Skip non-user/assistant roles if (role is not ("user" or "assistant")) continue; // Content is an array of blocks: [{type: "text"/"thinking", text: "..."}] // Extract only pure text blocks, skip thinking-only messages var contentBlocks = msg["content"] as JsonArray; if (contentBlocks is null) continue; var visibleTexts = new List(); foreach (var block in contentBlocks.Cast()) { if (block is null) continue; var type = block["type"]?.GetValue() ?? ""; var text = block["text"]?.GetValue() ?? ""; if (type == "text" && !string.IsNullOrWhiteSpace(text)) visibleTexts.Add(text); } var visibleContent = string.Join(" ", visibleTexts).Trim(); if (string.IsNullOrWhiteSpace(visibleContent)) continue; // Skip system-only replies if (visibleContent is "REPLY_SKIP" or "ANNOUNCE_SKIP") continue; var timestamp = msg["timestamp"]?.GetValue() ?? DateTimeOffset.UtcNow.ToString("o"); messages.Add(new MessageEntry(role, visibleContent, timestamp)); } return messages; } catch { return new List(); } } public async Task SendChatMessageAsync(string agentId, string message) { try { var result = await InvokeToolAsync("sessions_send", new { agentId, message }); if (result is null) return new ChatResponse(false, null, "Gateway nicht erreichbar"); // sessions_send reply is in details.reply or content[0].text var details = result["details"]; var ok = (details?["status"]?.GetValue() ?? result["status"]?.GetValue()) == "ok"; var reply = details?["reply"]?.GetValue() ?? result["reply"]?.GetValue() ?? result["response"]?.GetValue() ?? result["content"]?[0]?["text"]?.GetValue(); var error = details?["error"]?.GetValue() ?? result["error"]?.GetValue(); return new ChatResponse(ok, reply, error); } catch (Exception ex) { return new ChatResponse(false, null, $"Fehler: {ex.Message}"); } } public async Task> GetQueueAsync() { try { var result = await InvokeToolAsync("cron", new { action = "list" }); var data = ExtractToolData(result); if (data is null) return new List(); var items = new List(); var jobs = data["jobs"] as JsonArray ?? data.AsArray(); if (jobs is null) return items; foreach (var j in jobs) { if (j is null) continue; var id = j["id"]?.GetValue() ?? ""; var name = j["name"]?.GetValue() ?? id; var status = j["state"]?["lastStatus"]?.GetValue() ?? j["status"]?.GetValue() ?? "unknown"; items.Add(new QueueItem(id, name, status)); } return items; } catch { return new List(); } } public async Task GetStatusAsync() { var gatewayOk = false; var irisStatus = "Offline"; var activeAgents = 0; var pendingTasks = 0; // Step 1: Health check (no auth needed) try { using var pingRequest = new HttpRequestMessage(HttpMethod.Get, "/health"); using var pingResponse = await httpClient.SendAsync(pingRequest); gatewayOk = pingResponse.IsSuccessStatusCode; } catch { // gatewayOk stays false } if (gatewayOk) { // Step 2: Session status try { var sessionResult = await InvokeToolAsync("session_status"); if (sessionResult is not null) { irisStatus = sessionResult["status"]?.GetValue() ?? sessionResult["sessionKey"]?.GetValue() ?? "Active"; } } catch { } // Step 3: Active agents try { var agents = await GetAgentsAsync(); activeAgents = agents.Count(a => a.IsActive); } catch { } // Step 4: Queue items try { var queue = await GetQueueAsync(); pendingTasks = queue.Count; } catch { } } return new DashboardStatus(gatewayOk, irisStatus, activeAgents, pendingTasks); } private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "Orchestrator", "programmer" => "Developer", "reviewer" => "Reviewer", "architekt" => "Architect", "executor" => "Executor", "researcher" => "Researcher", "main" => "Assistant", _ => "Custom" }; }