feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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<byte[]> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user