using System.Net; using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; using Lskj.AgentPet.Host.Core.Configuration; namespace Lskj.AgentPet.Host.Core.Attachments; public sealed class AstrBotAttachmentUploader : IAstrBotAttachmentUploader { private const int MaximumResponseBytes = 1024 * 1024; private static readonly Regex SafeAttachmentId = new( "^[A-Za-z0-9_-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly HttpClient _httpClient; private readonly HostConfiguration _configuration; public AstrBotAttachmentUploader(HttpClient httpClient, HostConfiguration configuration) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); } public async Task<(string AttachmentId, string Type)> UploadAsync( Stream content, string fileName, string mimeType, long sizeBytes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(content); if (!content.CanRead || sizeBytes <= 0 || string.IsNullOrWhiteSpace(fileName)) throw new HostError("attachment_invalid", "附件上传输入无效。"); using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(_configuration.AttachmentUploadTimeout); using HttpRequestMessage request = new( HttpMethod.Post, new Uri(_configuration.AstrBotBaseUri, "api/v1/files")); request.Headers.TryAddWithoutValidation("X-API-Key", _configuration.AstrBotApiKey); using MultipartFormDataContent multipart = new(); using StreamContent streamContent = new(content); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(mimeType); streamContent.Headers.ContentLength = sizeBytes; multipart.Add(streamContent, "file", fileName); request.Content = multipart; 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_file_auth_failed", "AstrBot API Key 无效或缺少 file scope。"); if (!response.IsSuccessStatusCode) throw new HostError("astrbot_upload_failed", "AstrBot 附件上传失败。"); await using Stream body = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false); byte[] bytes = await ReadBoundedAsync(body, timeout.Token).ConfigureAwait(false); try { using JsonDocument document = JsonDocument.Parse(bytes, new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow, MaxDepth = 16 }); JsonElement root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("status", out JsonElement status) || status.GetString() != "ok" || !root.TryGetProperty("data", out JsonElement data) || data.ValueKind != JsonValueKind.Object) throw new HostError("astrbot_upload_protocol_error", "AstrBot 附件响应格式无效。"); string attachmentId = RequiredString(data, "attachment_id", 128); string type = RequiredString(data, "type", 16); if (!SafeAttachmentId.IsMatch(attachmentId) || (type != "image" && type != "file")) throw new HostError("astrbot_upload_protocol_error", "AstrBot 附件响应字段无效。"); return (attachmentId, type); } catch (JsonException error) { throw new HostError("astrbot_upload_protocol_error", "AstrBot 附件响应不是有效 JSON。", error); } } private static async Task ReadBoundedAsync(Stream source, CancellationToken cancellationToken) { using MemoryStream target = new(); byte[] buffer = new byte[16 * 1024]; while (true) { int read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); if (read == 0) return target.ToArray(); if (target.Length + read > MaximumResponseBytes) throw new HostError("astrbot_upload_protocol_error", "AstrBot 附件响应超过 1 MB。"); target.Write(buffer, 0, read); } } private static string RequiredString(JsonElement source, string name, int maximumLength) { if (!source.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String) throw new HostError("astrbot_upload_protocol_error", "AstrBot 附件响应缺少字段。 "); string result = value.GetString() ?? string.Empty; if (string.IsNullOrWhiteSpace(result) || result.Length > maximumLength) throw new HostError("astrbot_upload_protocol_error", "AstrBot 附件响应字段无效。 "); return result; } }