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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lskj.AgentPet.Host.Core.Attachments;
|
||||
|
||||
public sealed record PendingAttachment(
|
||||
string AttachmentId,
|
||||
string FileName,
|
||||
string Type,
|
||||
string MimeType,
|
||||
long SizeBytes,
|
||||
string ContentSha256);
|
||||
|
||||
public interface IAttachmentPicker
|
||||
{
|
||||
Task<IReadOnlyList<string>> PickAsync(
|
||||
int maximumCount,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IAstrBotAttachmentUploader
|
||||
{
|
||||
Task<(string AttachmentId, string Type)> UploadAsync(
|
||||
Stream content,
|
||||
string fileName,
|
||||
string mimeType,
|
||||
long sizeBytes,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IAttachmentSession
|
||||
{
|
||||
Task<IReadOnlyList<PendingAttachment>> PickAndUploadAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
IReadOnlyList<PendingAttachment> Snapshot();
|
||||
bool Remove(string attachmentId);
|
||||
void Consume(IEnumerable<string> attachmentIds);
|
||||
}
|
||||
|
||||
public sealed class AttachmentSession : IAttachmentSession
|
||||
{
|
||||
private static readonly Regex SafeAttachmentId = new(
|
||||
"^[A-Za-z0-9_-]{8,128}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private readonly IAttachmentPicker _picker;
|
||||
private readonly IAstrBotAttachmentUploader _uploader;
|
||||
private readonly int _maximumCount;
|
||||
private readonly long _maximumFileBytes;
|
||||
private readonly long _maximumTotalBytes;
|
||||
private readonly object _sync = new();
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly Dictionary<string, PendingAttachment> _pending =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public AttachmentSession(
|
||||
IAttachmentPicker picker,
|
||||
IAstrBotAttachmentUploader uploader,
|
||||
int maximumCount = 3,
|
||||
long maximumFileBytes = 12 * 1024 * 1024,
|
||||
long maximumTotalBytes = 36 * 1024 * 1024)
|
||||
{
|
||||
_picker = picker ?? throw new ArgumentNullException(nameof(picker));
|
||||
_uploader = uploader ?? throw new ArgumentNullException(nameof(uploader));
|
||||
if (maximumCount is < 1 or > 3
|
||||
|| maximumFileBytes is < 1024 or > 12 * 1024 * 1024
|
||||
|| maximumTotalBytes < maximumFileBytes
|
||||
|| maximumTotalBytes > maximumCount * maximumFileBytes)
|
||||
throw new ArgumentOutOfRangeException(nameof(maximumCount));
|
||||
_maximumCount = maximumCount;
|
||||
_maximumFileBytes = maximumFileBytes;
|
||||
_maximumTotalBytes = maximumTotalBytes;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PendingAttachment>> PickAndUploadAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
int available;
|
||||
long existingBytes;
|
||||
lock (_sync)
|
||||
{
|
||||
available = _maximumCount - _pending.Count;
|
||||
existingBytes = _pending.Values.Sum(item => item.SizeBytes);
|
||||
}
|
||||
if (available <= 0)
|
||||
throw new HostError(
|
||||
"attachment_count_exceeded",
|
||||
$"最多只能附加 {_maximumCount} 个文件。");
|
||||
|
||||
IReadOnlyList<string> selected = await _picker.PickAsync(
|
||||
available,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (selected.Count > available)
|
||||
throw new HostError("attachment_count_exceeded", "选择的附件数量超过允许范围。");
|
||||
|
||||
foreach (string path in selected)
|
||||
{
|
||||
ValidatedFile file = ValidatePath(path);
|
||||
if (existingBytes + file.SizeBytes > _maximumTotalBytes)
|
||||
throw new HostError("attachment_total_size_exceeded", "待发送附件总大小超过限制。");
|
||||
|
||||
await using FileStream stream = new(
|
||||
file.FullPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
64 * 1024,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
if (stream.Length != file.SizeBytes)
|
||||
throw new HostError("attachment_changed", "附件在选择后发生变化,请重新选择。");
|
||||
string mimeType = await DetectAndValidateAsync(
|
||||
stream,
|
||||
file.Extension,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
stream.Position = 0;
|
||||
byte[] contentDigest = await SHA256.HashDataAsync(
|
||||
stream,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
string contentSha256 = Convert.ToHexString(contentDigest)
|
||||
.ToLowerInvariant();
|
||||
stream.Position = 0;
|
||||
(string attachmentId, string type) = await _uploader.UploadAsync(
|
||||
stream,
|
||||
file.FileName,
|
||||
mimeType,
|
||||
file.SizeBytes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!SafeAttachmentId.IsMatch(attachmentId)
|
||||
|| (type != "image" && type != "file"))
|
||||
throw new HostError("astrbot_upload_protocol_error", "AstrBot 返回了无效附件标识。");
|
||||
|
||||
string expectedType = mimeType.StartsWith("image/", StringComparison.Ordinal)
|
||||
? "image"
|
||||
: "file";
|
||||
if (type != expectedType)
|
||||
throw new HostError("astrbot_upload_protocol_error", "AstrBot 返回的附件类型不匹配。");
|
||||
PendingAttachment pending = new(
|
||||
attachmentId,
|
||||
file.FileName,
|
||||
type,
|
||||
mimeType,
|
||||
file.SizeBytes,
|
||||
contentSha256);
|
||||
lock (_sync) _pending[attachmentId] = pending;
|
||||
existingBytes += file.SizeBytes;
|
||||
}
|
||||
return Snapshot();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PendingAttachment> Snapshot()
|
||||
{
|
||||
lock (_sync)
|
||||
return _pending.Values.OrderBy(item => item.FileName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public bool Remove(string attachmentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachmentId) || !SafeAttachmentId.IsMatch(attachmentId))
|
||||
return false;
|
||||
lock (_sync) return _pending.Remove(attachmentId);
|
||||
}
|
||||
|
||||
public void Consume(IEnumerable<string> attachmentIds)
|
||||
{
|
||||
if (attachmentIds is null) return;
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (string id in attachmentIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(id)) _pending.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ValidatedFile ValidatePath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path ?? string.Empty);
|
||||
FileInfo file = new(fullPath);
|
||||
string fileName = Path.GetFileName(fullPath);
|
||||
string extension = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
if (!file.Exists
|
||||
|| (file.Attributes & FileAttributes.ReparsePoint) != 0
|
||||
|| file.Length <= 0
|
||||
|| file.Length > _maximumFileBytes
|
||||
|| string.IsNullOrWhiteSpace(fileName)
|
||||
|| fileName.Length > 128
|
||||
|| fileName.Any(char.IsControl)
|
||||
|| extension is not (".png" or ".jpg" or ".jpeg" or ".webp" or ".pdf" or ".xlsx" or ".csv"))
|
||||
throw new HostError("attachment_invalid", "附件不存在、格式不支持或大小超出限制。");
|
||||
return new ValidatedFile(fullPath, fileName, extension, file.Length);
|
||||
}
|
||||
catch (HostError) { throw; }
|
||||
catch (Exception error) when (error is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException
|
||||
or PathTooLongException)
|
||||
{
|
||||
throw new HostError("attachment_invalid", "无法安全读取所选附件。", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> DetectAndValidateAsync(
|
||||
Stream stream,
|
||||
string extension,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] header = new byte[Math.Min(64 * 1024, checked((int)stream.Length))];
|
||||
int total = 0;
|
||||
while (total < header.Length)
|
||||
{
|
||||
int read = await stream.ReadAsync(
|
||||
header.AsMemory(total, header.Length - total),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
}
|
||||
switch (extension)
|
||||
{
|
||||
case ".png" when Starts(header, total, new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a }):
|
||||
return "image/png";
|
||||
case ".jpg" or ".jpeg" when Starts(header, total, new byte[] { 0xff, 0xd8, 0xff }):
|
||||
return "image/jpeg";
|
||||
case ".webp" when total >= 12
|
||||
&& Encoding.ASCII.GetString(header, 0, 4) == "RIFF"
|
||||
&& Encoding.ASCII.GetString(header, 8, 4) == "WEBP":
|
||||
return "image/webp";
|
||||
case ".pdf" when Starts(header, total, Encoding.ASCII.GetBytes("%PDF-")):
|
||||
return "application/pdf";
|
||||
case ".csv":
|
||||
await ValidateUtf8CsvAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return "text/csv";
|
||||
case ".xlsx" when Starts(header, total, new byte[] { 0x50, 0x4b, 0x03, 0x04 }):
|
||||
ValidateXlsx(stream);
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
}
|
||||
throw new HostError("attachment_signature_invalid", "附件内容与扩展名不一致。");
|
||||
}
|
||||
|
||||
private static async Task ValidateUtf8CsvAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
stream.Position = 0;
|
||||
using StreamReader reader = new(
|
||||
stream,
|
||||
new UTF8Encoding(false, true),
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
bufferSize: 64 * 1024,
|
||||
leaveOpen: true);
|
||||
char[] buffer = new char[32 * 1024];
|
||||
while (true)
|
||||
{
|
||||
int read = await reader.ReadAsync(
|
||||
buffer.AsMemory(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
if (Array.IndexOf(buffer, '\0', 0, read) >= 0)
|
||||
throw new HostError(
|
||||
"attachment_signature_invalid",
|
||||
"CSV 附件包含二进制内容。");
|
||||
}
|
||||
}
|
||||
catch (DecoderFallbackException error)
|
||||
{
|
||||
throw new HostError("attachment_encoding_invalid", "CSV 必须使用 UTF-8 编码。", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateXlsx(Stream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
stream.Position = 0;
|
||||
using ZipArchive archive = new(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
if (archive.Entries.Count > 10000
|
||||
|| !archive.Entries.Any(item => item.FullName == "[Content_Types].xml")
|
||||
|| !archive.Entries.Any(item => item.FullName == "xl/workbook.xml")
|
||||
|| archive.Entries.Sum(item => item.Length) > 100 * 1024 * 1024)
|
||||
throw new HostError("attachment_signature_invalid", "XLSX 文件结构无效或解压后过大。");
|
||||
}
|
||||
catch (HostError) { throw; }
|
||||
catch (InvalidDataException error)
|
||||
{
|
||||
throw new HostError("attachment_signature_invalid", "XLSX 文件结构无效。", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Starts(byte[] value, int length, byte[] prefix)
|
||||
{
|
||||
if (length < prefix.Length) return false;
|
||||
for (int index = 0; index < prefix.Length; index++)
|
||||
if (value[index] != prefix[index]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record ValidatedFile(
|
||||
string FullPath,
|
||||
string FileName,
|
||||
string Extension,
|
||||
long SizeBytes);
|
||||
}
|
||||
|
||||
public sealed class DisabledAttachmentSession : IAttachmentSession
|
||||
{
|
||||
public static DisabledAttachmentSession Instance { get; } = new();
|
||||
private DisabledAttachmentSession() { }
|
||||
|
||||
public Task<IReadOnlyList<PendingAttachment>> PickAndUploadAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<IReadOnlyList<PendingAttachment>>(
|
||||
new HostError("attachments_unavailable", "当前宿主未启用附件功能。"));
|
||||
public IReadOnlyList<PendingAttachment> Snapshot() => Array.Empty<PendingAttachment>();
|
||||
public bool Remove(string attachmentId) => false;
|
||||
public void Consume(IEnumerable<string> attachmentIds) { }
|
||||
}
|
||||
Reference in New Issue
Block a user