import { HttpService } from "@nestjs/axios"; import { Injectable, ServiceUnavailableException } from "@nestjs/common"; import type { AgentMessage, AgentMessageRole, AgentSession, AgentSessionList, AgentSessionStatus, IAgentProvider, InjectResult, } from "@mosaic/shared"; import type { AgentProviderConfig } from "@prisma/client"; import { randomUUID } from "node:crypto"; import { EncryptionService } from "../../../security/encryption.service"; import { OpenClawSseBridge } from "./openclaw-sse.bridge"; const DEFAULT_SESSION_LIMIT = 50; const DEFAULT_MESSAGE_LIMIT = 50; const MAX_MESSAGE_LIMIT = 200; const OPENCLAW_PROVIDER_TYPE = "openclaw"; const API_TOKEN_KEYS = ["apiToken", "token", "bearerToken"] as const; const DISPLAY_NAME_KEYS = ["displayName", "label"] as const; type JsonRecord = Record; interface HttpErrorWithResponse { response?: { status?: number; }; } @Injectable() export class OpenClawProvider implements IAgentProvider { readonly providerId: string; readonly providerType = OPENCLAW_PROVIDER_TYPE; readonly displayName: string; constructor( private readonly config: AgentProviderConfig, private readonly encryptionService: EncryptionService, private readonly httpService: HttpService, private readonly sseBridge: OpenClawSseBridge ) { this.providerId = this.config.name; this.displayName = this.resolveDisplayName(); } validateBaseUrl(): void { void this.resolveBaseUrl(); } validateToken(): void { void this.resolveApiToken(); } async listSessions(cursor?: string, limit = DEFAULT_SESSION_LIMIT): Promise { const safeLimit = this.normalizeLimit(limit, DEFAULT_SESSION_LIMIT); const params: Record = { limit: safeLimit }; if (typeof cursor === "string" && cursor.length > 0) { params.cursor = cursor; } try { const response = await this.httpService.axiosRef.get(this.buildUrl("/api/sessions"), { headers: this.authHeaders(), params, }); const page = this.extractSessionPage(response.data); const sessions = page.records .map((record) => this.toAgentSession(record)) .filter((session): session is AgentSession => session !== null); return { sessions, total: page.total ?? sessions.length, ...(page.cursor !== undefined ? { cursor: page.cursor } : {}), }; } catch (error) { throw this.toServiceUnavailable("list sessions", error); } } async getSession(sessionId: string): Promise { try { const response = await this.httpService.axiosRef.get( this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}`), { headers: this.authHeaders(), } ); const payload = this.unwrapContainer(response.data, ["session", "data"]); return this.toAgentSession(payload); } catch (error) { if (this.getHttpStatus(error) === 404) { return null; } throw this.toServiceUnavailable(`get session ${sessionId}`, error); } } async getMessages( sessionId: string, limit = DEFAULT_MESSAGE_LIMIT, before?: string ): Promise { const safeLimit = this.normalizeLimit(limit, DEFAULT_MESSAGE_LIMIT); const params: Record = { sessionId, limit: safeLimit, }; if (typeof before === "string" && before.length > 0) { params.before = before; } try { const response = await this.httpService.axiosRef.get(this.buildUrl("/api/messages"), { headers: this.authHeaders(), params, }); return this.extractMessageRecords(response.data) .map((record) => this.toAgentMessage(record, sessionId)) .filter((message): message is AgentMessage => message !== null); } catch (error) { throw this.toServiceUnavailable(`get messages for session ${sessionId}`, error); } } async injectMessage(sessionId: string, content: string): Promise { try { const response = await this.httpService.axiosRef.post( this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/inject`), { content }, { headers: this.authHeaders(), } ); const payload = this.isRecord(response.data) ? response.data : {}; return { accepted: typeof payload.accepted === "boolean" ? payload.accepted : true, ...(this.readString(payload.messageId) !== undefined ? { messageId: this.readString(payload.messageId) } : {}), }; } catch (error) { throw this.toServiceUnavailable(`inject message into session ${sessionId}`, error); } } async pauseSession(sessionId: string): Promise { try { await this.httpService.axiosRef.post( this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/pause`), {}, { headers: this.authHeaders(), } ); } catch (error) { throw this.toServiceUnavailable(`pause session ${sessionId}`, error); } } async resumeSession(sessionId: string): Promise { try { await this.httpService.axiosRef.post( this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/resume`), {}, { headers: this.authHeaders(), } ); } catch (error) { throw this.toServiceUnavailable(`resume session ${sessionId}`, error); } } async killSession(sessionId: string, force = true): Promise { try { await this.httpService.axiosRef.post( this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/kill`), { force }, { headers: this.authHeaders(), } ); } catch (error) { throw this.toServiceUnavailable(`kill session ${sessionId}`, error); } } async *streamMessages(sessionId: string): AsyncIterable { try { yield* this.sseBridge.streamSession(this.resolveBaseUrl(), sessionId, this.authHeaders()); } catch (error) { throw this.toServiceUnavailable(`stream messages for session ${sessionId}`, error); } } async isAvailable(): Promise { try { this.validateBaseUrl(); this.validateToken(); await this.httpService.axiosRef.get(this.buildUrl("/api/sessions"), { headers: this.authHeaders(), params: { limit: 1 }, }); return true; } catch { return false; } } private extractSessionPage(payload: unknown): { records: unknown[]; total?: number; cursor?: string; } { if (Array.isArray(payload)) { return { records: payload, total: payload.length, }; } if (!this.isRecord(payload)) { return { records: [], }; } let records: unknown[] = []; if (Array.isArray(payload.sessions)) { records = payload.sessions; } else if (Array.isArray(payload.items)) { records = payload.items; } else if (Array.isArray(payload.data)) { records = payload.data; } const total = typeof payload.total === "number" ? payload.total : undefined; const cursor = this.readString(payload.cursor) ?? this.readString(payload.nextCursor); return { records, total, ...(cursor !== undefined ? { cursor } : {}), }; } private extractMessageRecords(payload: unknown): unknown[] { if (Array.isArray(payload)) { return payload; } if (!this.isRecord(payload)) { return []; } if (Array.isArray(payload.messages)) { return payload.messages; } if (Array.isArray(payload.items)) { return payload.items; } if (Array.isArray(payload.data)) { return payload.data; } return []; } private unwrapContainer(payload: unknown, keys: string[]): unknown { if (!this.isRecord(payload)) { return payload; } for (const key of keys) { if (key in payload) { return payload[key]; } } return payload; } private toAgentSession(record: unknown): AgentSession | null { if (!this.isRecord(record)) { return null; } const id = this.readString(record.id) ?? this.readString(record.sessionId) ?? this.readString(record.key); if (!id) { return null; } const createdAt = this.parseDate(record.createdAt ?? record.spawnedAt ?? record.startedAt); const updatedAt = this.parseDate( record.updatedAt ?? record.completedAt ?? record.lastActivityAt ?? record.endedAt, createdAt ); const label = this.readString(record.label) ?? this.readString(record.title) ?? this.readString(record.name) ?? undefined; const parentSessionId = this.readString(record.parentSessionId) ?? undefined; const metadata = this.toMetadata(record.metadata); return { id, providerId: this.providerId, providerType: this.providerType, ...(label !== undefined ? { label } : {}), status: this.toSessionStatus(this.readString(record.status)), ...(parentSessionId !== undefined ? { parentSessionId } : {}), createdAt, updatedAt, ...(metadata !== undefined ? { metadata } : {}), }; } private toAgentMessage(value: unknown, fallbackSessionId?: string): AgentMessage | null { if (typeof value === "string") { const content = value.trim(); if (content.length === 0 || fallbackSessionId === undefined) { return null; } return { id: randomUUID(), sessionId: fallbackSessionId, role: "assistant", content, timestamp: new Date(), }; } let candidate: JsonRecord | null = null; if (this.isRecord(value) && this.isRecord(value.message)) { candidate = value.message; } else if (this.isRecord(value)) { candidate = value; } if (candidate === null) { return null; } const sessionId = this.readString(candidate.sessionId) ?? fallbackSessionId; if (!sessionId) { return null; } const content = this.extractMessageContent( candidate.content ?? candidate.text ?? candidate.message ); if (content.length === 0) { return null; } const metadata = this.toMetadata(candidate.metadata); return { id: this.readString(candidate.id) ?? this.readString(candidate.messageId) ?? randomUUID(), sessionId, role: this.toMessageRole(this.readString(candidate.role) ?? this.readString(candidate.type)), content, timestamp: this.parseDate(candidate.timestamp ?? candidate.createdAt), ...(metadata !== undefined ? { metadata } : {}), }; } private extractMessageContent(content: unknown): string { if (typeof content === "string") { return content.trim(); } if (Array.isArray(content)) { const parts: string[] = []; for (const part of content) { if (typeof part === "string") { const trimmed = part.trim(); if (trimmed.length > 0) { parts.push(trimmed); } continue; } if (!this.isRecord(part)) { continue; } const text = this.readString(part.text) ?? this.readString(part.content); if (text !== undefined && text.trim().length > 0) { parts.push(text.trim()); } } return parts.join("\n\n").trim(); } if (this.isRecord(content)) { const text = this.readString(content.text) ?? this.readString(content.content); return text?.trim() ?? ""; } return ""; } private toSessionStatus(status?: string): AgentSessionStatus { switch (status?.toLowerCase()) { case "active": case "running": return "active"; case "paused": return "paused"; case "completed": case "done": case "succeeded": return "completed"; case "failed": case "error": case "killed": case "terminated": case "cancelled": return "failed"; case "idle": case "pending": case "queued": default: return "idle"; } } private toMessageRole(role?: string): AgentMessageRole { switch (role?.toLowerCase()) { case "assistant": case "agent": return "assistant"; case "system": return "system"; case "tool": return "tool"; case "operator": case "user": default: return "user"; } } private normalizeLimit(value: number, fallback: number): number { const normalized = Number.isFinite(value) ? Math.trunc(value) : fallback; if (normalized < 1) { return 1; } return Math.min(normalized, MAX_MESSAGE_LIMIT); } private parseDate(value: unknown, fallback = new Date()): Date { if (value instanceof Date) { return value; } if (typeof value === "string" || typeof value === "number") { const parsed = new Date(value); if (!Number.isNaN(parsed.getTime())) { return parsed; } } return fallback; } private toMetadata(value: unknown): Record | undefined { if (this.isRecord(value)) { return value; } return undefined; } private resolveDisplayName(): string { const credentials = this.readCredentials(); for (const key of DISPLAY_NAME_KEYS) { const value = this.readString(credentials[key]); if (value !== undefined) { return value; } } return this.config.name; } private resolveBaseUrl(): string { const configRecord = this.config as unknown as JsonRecord; const rawBaseUrl = this.readString(this.config.gatewayUrl) ?? this.readString(configRecord.baseUrl); if (rawBaseUrl === undefined) { throw new Error(`OpenClaw provider ${this.providerId} is missing gateway URL`); } try { const parsed = new URL(rawBaseUrl); return parsed.toString().replace(/\/$/u, ""); } catch { throw new Error(`OpenClaw provider ${this.providerId} has invalid gateway URL`); } } private resolveApiToken(): string { const configRecord = this.config as unknown as JsonRecord; const credentials = this.readCredentials(); const rawToken = this.readString(configRecord.apiToken) ?? this.readString(configRecord.token) ?? this.readString(configRecord.bearerToken) ?? this.findFirstString(credentials, API_TOKEN_KEYS); if (rawToken === undefined) { throw new Error(`OpenClaw provider ${this.providerId} is missing apiToken credentials`); } try { return this.encryptionService.decryptIfNeeded(rawToken); } catch (error) { throw new Error(`Failed to decrypt API token: ${this.toErrorMessage(error)}`); } } private readCredentials(): JsonRecord { return this.isRecord(this.config.credentials) ? this.config.credentials : {}; } private findFirstString(record: JsonRecord, keys: readonly string[]): string | undefined { for (const key of keys) { const value = this.readString(record[key]); if (value !== undefined) { return value; } } return undefined; } private authHeaders(extraHeaders: Record = {}): Record { return { Authorization: `Bearer ${this.resolveApiToken()}`, ...extraHeaders, }; } private buildUrl(path: string): string { return new URL(path, `${this.resolveBaseUrl()}/`).toString(); } private isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } private readString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } private getHttpStatus(error: unknown): number | undefined { if (typeof error !== "object" || error === null || !("response" in error)) { return undefined; } const response = (error as HttpErrorWithResponse).response; return typeof response?.status === "number" ? response.status : undefined; } private toServiceUnavailable(operation: string, error: unknown): ServiceUnavailableException { return new ServiceUnavailableException( `OpenClaw provider ${this.providerId} failed to ${operation}: ${this.toErrorMessage(error)}` ); } private toErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } }