using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.RegularExpressions; using Lskj.AgentPet.Host.Core.Configuration; using Lskj.AgentPet.Host.Core.Security; namespace Lskj.AgentPet.Host.Core.AstrBot; public sealed record AstrBotStreamEvent( string Type, JsonElement Data, string? ChainType, bool Streaming, string? RunId, string? SessionId); public sealed record AstrBotChatAttachment( string AttachmentId, string FileName, string Type); public sealed record AstrBotChatRequest( string Text, IReadOnlyList Attachments) { /// /// Host-generated, already-sanitized ERP execution evidence. Browser input /// can never populate this property. /// public string? TrustedContext { get; init; } } public interface IAstrBotChatClient { IAsyncEnumerable StreamAsync( AstrBotChatRequest request, CancellationToken cancellationToken = default); } public sealed class AstrBotChatClient : IAstrBotChatClient { private static readonly Regex SafeType = new( "^[a-zA-Z0-9_.:-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeAttachmentId = new( "^[A-Za-z0-9_-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeRunId = new( "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly HttpClient _httpClient; private readonly HostConfiguration _configuration; public AstrBotChatClient(HttpClient httpClient, HostConfiguration configuration) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); } public async IAsyncEnumerable StreamAsync( AstrBotChatRequest chatRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(chatRequest); string text = (chatRequest.Text ?? string.Empty).Trim(); IReadOnlyList attachments = chatRequest.Attachments ?? Array.Empty(); if (text.Length > 2000 || (text.Length == 0 && attachments.Count == 0)) throw new HostError("chat_text_invalid", "聊天内容为空或超过 2000 个字符。"); string? trustedContext = chatRequest.TrustedContext; if (trustedContext is not null && !TrustedDiagnosticContextStore.IsWellFormedPrompt(trustedContext)) throw new HostError( "trusted_context_invalid", "本机 ERP 诊断上下文格式无效。"); if (attachments.Count > _configuration.MaximumAttachmentCount) throw new HostError("attachment_count_exceeded", "聊天附件数量超过允许范围。"); object message = BuildMessage(text, attachments, trustedContext); using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(_configuration.ChatTimeout); using HttpRequestMessage request = new( HttpMethod.Post, new Uri(_configuration.AstrBotBaseUri, "api/v1/chat")); request.Headers.TryAddWithoutValidation("X-API-Key", _configuration.AstrBotApiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); request.Content = JsonContent.Create(new { session_id = _configuration.AstrBotSessionId, message, flags = new { enable_inline_genui = false, enable_default_system_prompt = true, enable_streaming = true } }); using HttpResponseMessage response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) throw new HostError("astrbot_auth_failed", "AstrBot API Key 无效或缺少 chat scope。"); if (!response.IsSuccessStatusCode) throw new HostError("astrbot_http_error", "AstrBot 对话服务暂时不可用。"); string mediaType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; if (!string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase)) throw new HostError("astrbot_protocol_error", "AstrBot 未返回 SSE 对话流。"); await using Stream stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false); bool sessionBound = false; string? boundRunId = null; await foreach (SseFrame frame in SseParser.ReadAsync(stream, timeout.Token).ConfigureAwait(false)) { if (string.IsNullOrWhiteSpace(frame.Data)) continue; JsonDocument document; try { document = JsonDocument.Parse(frame.Data, new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow, MaxDepth = 64 }); } catch (JsonException error) { throw new HostError("astrbot_protocol_error", "AstrBot SSE 包含无效 JSON。", error); } using (document) { JsonElement root = document.RootElement; if (root.ValueKind != JsonValueKind.Object) throw new HostError("astrbot_protocol_error", "AstrBot SSE 事件必须是 JSON 对象。"); EnsureUniqueProperties(root); string type = RequiredProtocolString(root, "type", 64); if (!SafeType.IsMatch(type)) throw new HostError("astrbot_protocol_error", "AstrBot SSE 事件类型无效。"); string? chainType = OptionalProtocolString(root, "chain_type", 64); string? sessionId = OptionalProtocolString(root, "session_id", 256); string? messageId = OptionalProtocolString(root, "message_id", 128); string? legacyRunId = OptionalProtocolString(root, "run_id", 128); string? runId = ResolveRunId(messageId, legacyRunId); if (!sessionBound) { if (!string.Equals(type, "session_id", StringComparison.Ordinal)) throw new HostError( "astrbot_session_binding_required", "AstrBot SSE 未先回显本轮会话标识。"); ValidateSessionId(sessionId); sessionBound = true; } else if (sessionId is not null) { ValidateSessionId(sessionId); } if (runId is not null) { if (!SafeRunId.IsMatch(runId)) throw new HostError( "astrbot_protocol_error", "AstrBot SSE 运行标识格式无效。"); if (boundRunId is null) boundRunId = runId; else if (!string.Equals(boundRunId, runId, StringComparison.Ordinal)) throw new HostError( "astrbot_run_mismatch", "AstrBot SSE 混入了另一轮运行事件。"); } if (RequiresRunBinding(type, chainType) && runId is null) throw new HostError( "astrbot_run_binding_required", "AstrBot SSE 关键事件缺少本轮运行标识。"); JsonElement data = root.TryGetProperty("data", out JsonElement value) ? value.Clone() : JsonSerializer.SerializeToElement(null); yield return new AstrBotStreamEvent( type, data, chainType, Boolean(root, "streaming"), runId, sessionId); } } } private void ValidateSessionId(string? sessionId) { if (!string.Equals( sessionId, _configuration.AstrBotSessionId, StringComparison.Ordinal)) throw new HostError( "astrbot_session_mismatch", "AstrBot SSE 会话标识与请求不一致。"); } private static string? ResolveRunId(string? messageId, string? legacyRunId) { if (messageId is not null && legacyRunId is not null && !string.Equals(messageId, legacyRunId, StringComparison.Ordinal)) throw new HostError( "astrbot_run_mismatch", "AstrBot SSE 同时返回了冲突的运行标识。"); return messageId ?? legacyRunId; } private static bool RequiresRunBinding(string type, string? chainType) { return string.Equals(type, "end", StringComparison.Ordinal) || string.Equals(chainType, "tool_call", StringComparison.OrdinalIgnoreCase) || string.Equals(chainType, "tool_call_result", StringComparison.OrdinalIgnoreCase); } private static void EnsureUniqueProperties(JsonElement value) { HashSet names = new(StringComparer.Ordinal); foreach (JsonProperty property in value.EnumerateObject()) { if (!names.Add(property.Name)) throw new HostError( "astrbot_protocol_error", "AstrBot SSE 事件包含重复字段。"); } } private static string RequiredProtocolString( JsonElement value, string name, int maximumLength) { return OptionalProtocolString(value, name, maximumLength) ?? throw new HostError( "astrbot_protocol_error", "AstrBot SSE 事件缺少必要字段。"); } private static string? OptionalProtocolString( JsonElement value, string name, int maximumLength) { if (!value.TryGetProperty(name, out JsonElement item)) return null; if (item.ValueKind != JsonValueKind.String) throw new HostError( "astrbot_protocol_error", "AstrBot SSE 身份字段类型无效。"); string result = item.GetString() ?? string.Empty; if (result.Length is < 1 || result.Length > maximumLength || result.Any(char.IsControl) || !string.Equals(result, result.Trim(), StringComparison.Ordinal)) throw new HostError( "astrbot_protocol_error", "AstrBot SSE 身份字段内容无效。"); return result; } private static bool Boolean(JsonElement value, string name) { return value.TryGetProperty(name, out JsonElement item) && item.ValueKind is JsonValueKind.True; } private static object BuildMessage( string text, IReadOnlyList attachments, string? trustedContext) { if (attachments.Count == 0 && trustedContext is null) return text; List parts = new(); if (text.Length > 0) parts.Add(new { type = "plain", text }); if (trustedContext is not null) parts.Add(new { type = "plain", text = trustedContext }); foreach (AstrBotChatAttachment attachment in attachments) { string fileName = (attachment.FileName ?? string.Empty).Trim(); if (!SafeAttachmentId.IsMatch(attachment.AttachmentId ?? string.Empty) || (attachment.Type != "image" && attachment.Type != "file") || fileName.Length is < 1 or > 128 || fileName.Any(char.IsControl) || fileName.Contains('/') || fileName.Contains('\\')) throw new HostError("attachment_invalid", "聊天附件字段无效。"); parts.Add(new { type = attachment.Type, attachment_id = attachment.AttachmentId, filename = fileName }); } return parts; } }