Files
lserp_cs_6.0/插件库/Lskj.AgentPet.Host.Core/Attachments/AttachmentSession.cs
T
2026-08-14 14:28:28 +08:00

331 lines
13 KiB
C#

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) { }
}