diff --git a/src/plugins/AGENTS.md b/src/plugins/AGENTS.md index f434e72..6cb71d8 100644 --- a/src/plugins/AGENTS.md +++ b/src/plugins/AGENTS.md @@ -326,19 +326,25 @@ TypedPluginLoader.Register(); | 路径 | `src/plugins/ZeroBot.Painter/` | | 命名空间 | `ZeroBot.Painter` | -**功能:** 小画家绘图,群聊内通过指令调用后台绘图训练服务生成图片,按群与群员做每日用量限制。 +**功能:** 小画家绘图,群聊内通过指令调用后台绘图训练服务生成图片,按群与群员做每日用量限制;支持群公共素材库(图片素材随消息或引用消息添加,绘图时按名称引用)。 **组件:** - `PainterManageCommandHandler` - 启用/禁用指令 (`/小画家:启用`、`/小画家:禁用`) +- `PainterMaterialCommandHandler` - 公共素材指令 (`/小画家:公共素材`,增删需高权限,列出对群员开放) - `PaintCommandHandler` - 绘图指令 (`/小画家:画`,`CommandQueuedHandler` + 表情反馈) -- `PainterApi` - 绘图接口 HTTP 客户端(multipart 上传,请求级超时热生效) +- `PainterApi` - 绘图接口 HTTP 客户端(multipart 上传,请求级超时热生效;失败时回发上游状态码与响应体原文,截断至 300 字符) +- `PainterMaterialStore` - 公共素材落盘(工作目录 `painter/{群uid}/{名称SHA-256前16位}/`,含 `material.json` 与图片文件) +- `PainterImages` - 引用消息 / 本消息图片收集(引用消息的图片优先,不复用只取首图的 `GetMilkyImageMessagesAsync`) - `PainterQuota` - UTC+8 每日用量/日期/上限校验工具 **命令:** -- `/小画家:启用:{number}` / `/小画家:禁用` - 开启/关闭本群绘图功能(仅群聊,高权限用户,权限 `painter.manage`;number 为每人每日上限,1~5) -- `/小画家:画:{prompt}` - 调用绘图接口并把结果发回群聊(仅群聊,附 0~10 张参考图,受群内每人每日上限限制;未开启的群完全静默) +- `/小画家:启用:{number}` / `/小画家:禁用` - 开启/关闭本群绘图功能(仅群聊,高权限用户,权限 `painter.manage`;number 为每人每日上限,1~5,-1 不限制) +- `/小画家:画:{prompt}` - 调用绘图接口并把结果发回群聊(仅群聊,附 0~10 张参考图,受群内每人每日上限限制;未开启的群完全静默)。参考图优先级:引用消息中的图片 > 本条消息中的图片 > 提示词开头 `{name}` 引用的公共素材图片;`{name}` 未命中素材时原样保留在提示词中 +- `/小画家:公共素材` - 列出本群公共素材(仅群聊、本群已开启,所有群员可用) +- `/小画家:公共素材:添加:{name}` - 把本条消息(或引用消息)中的图片添加为公共素材,同名覆盖(高权限用户,权限 `painter.manage`) +- `/小画家:公共素材:删除:{name}` - 删除本群公共素材(高权限用户,权限 `painter.manage`) -**配置文件:** `painter-config.json`(`Endpoint` 支持热加载,默认可用环境变量 `Z_VTUBER_TRAINING_ENDPOINT`;`HttpTimeoutSeconds` 默认 600;`Groups` 为群 PeerId → 每人每日上限;`DailyQuotas` 为当日用量表) +**配置文件:** `painter-config.json`(`Endpoint` 支持热加载,默认可用环境变量 `Z_VTUBER_TRAINING_ENDPOINT`;`HttpTimeoutSeconds` 默认 600;`MaxImages` 单次最多参考图数,默认 10;`Groups` 为群 PeerId → 每人每日上限;`DailyQuotas` 为当日用量表) --- @@ -349,16 +355,18 @@ TypedPluginLoader.Register(); | 路径 | `src/plugins/ZeroBot.Workflow/` | | 命名空间 | `ZeroBot.Workflow` | -**功能:** 工作流执行插件,通过后台工作流服务运行脚本并按结果类型(音频/文件)发回群聊。 +**功能:** 工作流执行插件,通过后台工作流服务运行脚本并按结果类型(音频/文件)发回群聊;群功能默认关闭,需由 bot 管理员启用。 **组件:** - `WorkflowCommandHandler` - 工作流指令 (`/workflow:run`,`CommandQueuedHandler` + 表情反馈) +- `WorkflowManageCommandHandler` - 群开关指令 (`/workflow:enable`、`/workflow:disable`) - `WorkflowApi` - 工作流服务 HTTP 客户端(先 `/v1/compile` 校验展开,再 `/v1/run` 执行;请求级超时) **命令:** -- `/workflow:run:{file|voice}:{script}` - 仅群聊、仅高权限用户(sudoers / 群管理员)可用;`script` 吃掉第二个分隔符之后的全部内容(可换行),`file` 发送文件、`voice` 发送语音 +- `/workflow:enable` / `/workflow:disable` - 开启/关闭本群工作流功能(仅群聊,仅 bot 管理员即 sudoers,群管理员无权操作) +- `/workflow:run:{file|voice}:{script}` - 仅群聊、需本群已启用、仅高权限用户(sudoers / 群管理员)可用;`script` 吃掉第二个分隔符之后的全部内容(可换行),`file` 发送文件、`voice` 发送语音;未启用的群静默忽略 -**配置:** 配置节 `WorkflowService`,例如环境变量 `WorkflowService__BaseUrl`(服务基地址);`WorkflowService__HttpTimeoutSeconds`(单次请求超时秒数,默认 600) +**配置:** 配置节 `WorkflowService`,例如环境变量 `WorkflowService__BaseUrl`(服务基地址);`WorkflowService__HttpTimeoutSeconds`(单次请求超时秒数,默认 1800 即 30 分钟)。群开关持久化于热加载的 `workflow-config.json`(`EnabledGroups` 为已启用群 PeerId 集合) **依赖:** `ZeroAsh.Milky.Net.Client`(通过群文件上传发送 `file` 结果) diff --git a/src/plugins/ZeroBot.Painter/PaintCommandHandler.cs b/src/plugins/ZeroBot.Painter/PaintCommandHandler.cs index e0ad5c4..3dcc7ae 100644 --- a/src/plugins/ZeroBot.Painter/PaintCommandHandler.cs +++ b/src/plugins/ZeroBot.Painter/PaintCommandHandler.cs @@ -1,3 +1,4 @@ +using System.Text; using Microsoft.Extensions.Logging; using Milky.Net.Model; using ZeroBot.Abstraction.Bot; @@ -12,6 +13,10 @@ namespace ZeroBot.Painter; /// 同群同人每天(UTC+8)受该群上限限制,通过限额检查后、调用接口之前即消耗一次用量, /// 失败调用同样计入且不回滚。 /// +/// 参赛图片的优先级:引用消息中的图片 > 本条消息中的图片 > 提示词开头 {name} 引用的公共素材图片。 +/// 提示词开头的 {name} 引用命中本群公共素材时从提示词中移除并追加其图片, +/// 未命中则原样保留(不替换)。 +/// /// 群上限在请求发起时(派发阶段)捕获,排队期间管理员禁用/调整上限不影响在途请求, /// 保证「以发起时状态为准,进行中的调用完成并回发结果」(PRD §5)。 /// @@ -20,6 +25,7 @@ public class PaintCommandHandler( IBotContext bot, IJsonConfig config, PainterApi api, + PainterMaterialStore store, ILogger logger) : CommandQueuedHandler(dispatcher) { private const string CommandPrefix = "/小画家:画"; @@ -70,6 +76,32 @@ public static bool TryParsePrompt(string raw, out string prompt) return true; } + /// + /// 切出提示词开头连续的 {name} 引用。 + /// 返回每个引用的原文(含花括号)与其中的名称,以及引用之后的剩余提示词; + /// 首字符不是 {、{} 或缺少右花括号时停止切分(后续内容整体作为剩余提示词)。 + /// 名称非法(空)的引用视为普通文本,停止切分。 + /// + public static (List<(string Raw, string Name)> References, string Remaining) SplitMaterialReferences(string prompt) + { + var references = new List<(string Raw, string Name)>(); + var remaining = prompt; + + while (remaining.Length > 1 && remaining[0] == '{') + { + var close = remaining.IndexOf('}'); + if (close <= 1) break; + + var name = remaining[1..close].Trim(); + if (name.Length == 0) break; + + references.Add((remaining[..(close + 1)], name)); + remaining = remaining[(close + 1)..]; + } + + return (references, remaining); + } + protected override ValueTask PredicateAsync(Event message, CancellationToken cancellationToken = default) { @@ -131,7 +163,25 @@ protected override async ValueTask DequeueAsync(Event @event, return; } - if (string.IsNullOrWhiteSpace(prompt)) + // 提示词开头的 {name} 引用本群公共素材:命中则移除占位符并追加其图片,未命中则保留原文。 + var (references, remainingPrompt) = SplitMaterialReferences(prompt.TrimStart()); + var materialNames = new List(); + var builder = new StringBuilder(); + foreach (var (rawReference, materialName) in references) + { + if (store.Find(peerId, materialName) is null) + { + builder.Append(rawReference); + continue; + } + + if (!materialNames.Contains(materialName)) materialNames.Add(materialName); + } + + builder.Append(remainingPrompt); + var finalPrompt = builder.ToString(); + + if (string.IsNullOrWhiteSpace(finalPrompt)) { await @event.ReplyAsGroup(bot, cancellationToken, ["提示词不能为空,请使用:/小画家:画:{prompt}".ToMilkyTextSegment()]); @@ -145,34 +195,46 @@ await @event.ReplyAsGroup(bot, cancellationToken, return; } - byte[]? bytes; + PaintResult result; try { var options = config.Current; - var images = new List(); - foreach (var seg in @event.Data.Segments.OfType().Take(options.MaxImages)) + + // 优先级:引用消息的图片 > 本条消息的图片 > 提示词引用素材的图片,总计不超过 MaxImages 张。 + var images = await PainterImages.CollectContextImagesAsync(bot, @event, options.MaxImages, + cancellationToken); + foreach (var materialName in materialNames) { - images.Add(await seg.GetMilkyImageBytesAsync(bot, @event, cancellationToken)); + if (images.Count >= options.MaxImages) break; + + foreach (var materialImage in await store.LoadImagesAsync(peerId, materialName, cancellationToken)) + { + if (images.Count >= options.MaxImages) break; + images.Add(materialImage); + } } - bytes = await api.GenerateAsync(options.Endpoint, prompt, images, + result = await api.GenerateAsync(options.Endpoint, finalPrompt, images, TimeSpan.FromSeconds(options.HttpTimeoutSeconds), cancellationToken); } catch (Exception e) { // 取图或调用异常:仅记日志并按失败处理;用量已消耗,不回滚。 logger.LogError(e, "绘图调用异常"); - bytes = null; + result = PaintResult.Fail(null); } - if (bytes is null) + if (!result.Success || result.Data is not { Length: > 0 } image) { - await @event.ReplyAsGroup(bot, cancellationToken, - ["绘图失败,请稍后重试。".ToMilkyTextSegment()]); + // 带上上游返回的错误详情(PainterApi 已截断),便于用户与运维定位问题。 + var message = string.IsNullOrWhiteSpace(result.Error) + ? "绘图失败,请稍后重试。" + : $"绘图失败:{result.Error}"; + await @event.ReplyAsGroup(bot, cancellationToken, [message.ToMilkyTextSegment()]); return; } - await @event.ReplyAsGroup(bot, cancellationToken, [bytes.ToMilkyImageSegment()]); + await @event.ReplyAsGroup(bot, cancellationToken, [image.ToMilkyImageSegment()]); } catch (Exception e) { diff --git a/src/plugins/ZeroBot.Painter/PainterApi.cs b/src/plugins/ZeroBot.Painter/PainterApi.cs index fb1f05c..c695aab 100644 --- a/src/plugins/ZeroBot.Painter/PainterApi.cs +++ b/src/plugins/ZeroBot.Painter/PainterApi.cs @@ -3,6 +3,18 @@ namespace ZeroBot.Painter; +/// +/// 绘图接口调用结果:成功时 为图片二进制; +/// 失败时 为可直接回复到聊天中的错误信息(尽力包含上游返回的错误详情), +/// 无上游详情时为 null,由调用方回落到通用失败提示。 +/// +public sealed record PaintResult(bool Success, byte[]? Data, string? Error) +{ + public static PaintResult Ok(byte[] data) => new(true, data, null); + + public static PaintResult Fail(string? error) => new(false, null, error); +} + /// /// 绘图训练服务接口客户端。 /// 自持专用 (实例级超时置为无限),唯一超时来源是请求级 @@ -14,10 +26,14 @@ public class PainterApi(ILogger logger) // 也不与 SynthesizePlugin 注册的 AddSingleton() 抢解析。 private static readonly HttpClient Http = new() { Timeout = Timeout.InfiniteTimeSpan }; + /// 回复到聊天中的错误信息最大长度,超出截断,避免上游长响应刷屏。 + private const int MaxErrorLength = 300; + /// - /// 以 multipart/form-data 调用绘图接口,成功返回图片二进制;失败(非 2xx / 超时 / 异常 / 空体 / JSON 响应)返回 null。 + /// 以 multipart/form-data 调用绘图接口,成功返回图片二进制; + /// 失败(非 2xx / 超时 / 异常 / 空体 / JSON 响应)返回 ,尽量带上上游错误详情。 /// - public async Task GenerateAsync(string endpoint, string prompt, IReadOnlyList images, + public async Task GenerateAsync(string endpoint, string prompt, IReadOnlyList images, TimeSpan timeout, CancellationToken cancellationToken = default) { try @@ -40,34 +56,65 @@ public class PainterApi(ILogger logger) using var response = await Http.PostAsync(url, form, cts.Token); if (!response.IsSuccessStatusCode) { - logger.LogError("绘图请求失败,状态码 {Status}", response.StatusCode); - return null; + var body = await response.Content.ReadAsStringAsync(cts.Token); + logger.LogError("绘图请求失败,状态码 {Status},响应 {Body}", + response.StatusCode, Truncate(body, MaxErrorLength)); + return PaintResult.Fail(DescribeFailure(response, body)); } - // 防御分支:接口若改为 JSON 结构响应,本期不解析,避免把 JSON 当图片发出。 + // 防御分支:接口若改为 JSON 结构响应,本期不解析,避免把 JSON 当图片发出; + // 但尽量把响应内容(通常是错误详情)回传给用户,便于定位问题。 var mediaType = response.Content.Headers.ContentType?.MediaType; if (string.Equals(mediaType, "application/json", StringComparison.OrdinalIgnoreCase)) { - logger.LogError("绘图接口返回 JSON({MediaType}),本期不支持解析,按失败处理", mediaType); - return null; + var body = await response.Content.ReadAsStringAsync(cts.Token); + logger.LogError("绘图接口返回 JSON({MediaType}),本期不支持解析,按失败处理:{Body}", + mediaType, Truncate(body, MaxErrorLength)); + + var detail = NormalizeBody(body); + return PaintResult.Fail(string.IsNullOrWhiteSpace(detail) + ? "绘图接口返回了非图片响应" + : $"绘图接口返回了非图片响应:{detail}"); } var bytes = await response.Content.ReadAsByteArrayAsync(cts.Token); - return bytes.Length == 0 ? null : bytes; + return bytes.Length == 0 + ? PaintResult.Fail("绘图接口返回了空响应") + : PaintResult.Ok(bytes); } catch (TaskCanceledException e) when (!cancellationToken.IsCancellationRequested) { // 外层令牌未取消 → 请求级 CTS 触发,即超时。 - logger.LogError(e, "绘图请求超时({TimeoutSeconds} 秒)", timeout.TotalSeconds); - return null; + var seconds = timeout.TotalSeconds; + logger.LogError(e, "绘图请求超时({TimeoutSeconds} 秒)", seconds); + return PaintResult.Fail($"绘图请求超时({seconds:0.##} 秒)"); } catch (Exception e) { logger.LogError(e, "绘图请求异常"); - return null; + return PaintResult.Fail(null); } } + /// + /// 拼装非 2xx 的失败信息,形如 上游返回 500 Internal Server Error:{响应体}。 + /// + private static string DescribeFailure(HttpResponseMessage response, string body) + { + var status = $"上游返回 {(int)response.StatusCode} {response.StatusCode}"; + var detail = NormalizeBody(body); + return string.IsNullOrWhiteSpace(detail) ? status : $"{status}:{detail}"; + } + + /// + /// 把上游响应体按原文整理为可直接回复的错误信息:去首尾空白并截断;空白响应返回 null。 + /// + public static string? NormalizeBody(string? body) + => string.IsNullOrWhiteSpace(body) ? null : Truncate(body.Trim(), MaxErrorLength); + + private static string Truncate(string value, int maxLength) + => value.Length <= maxLength ? value : value[..maxLength] + "…"; + /// /// 按文件魔数嗅探图片类型,返回 (Content-Type, 扩展名);未知类型回落到通用二进制。 /// diff --git a/src/plugins/ZeroBot.Painter/PainterImages.cs b/src/plugins/ZeroBot.Painter/PainterImages.cs new file mode 100644 index 0000000..028fc09 --- /dev/null +++ b/src/plugins/ZeroBot.Painter/PainterImages.cs @@ -0,0 +1,65 @@ +using Milky.Net.Model; +using ZeroBot.Abstraction.Bot; +using ZeroBot.Utility; + +namespace ZeroBot.Painter; + +/// +/// 绘图与公共素材指令共用的图片收集。 +/// 刻意不复用 GetMilkyImageMessagesAsync(该扩展对每条消息只取首图), +/// 而是枚举全部 ,并保留「引用消息的图片在前」的优先级。 +/// +internal static class PainterImages +{ + /// + /// 被引用消息中的全部图片(按出现顺序);无引用、引用消息已过期或其中没有图片时返回空列表。 + /// + public static async ValueTask> CollectReferencedImagesAsync(IBotContext bot, + Event @event, int max, CancellationToken cancellationToken) + { + var images = new List(); + if (max <= 0) return images; + if (@event.Data.Segments.OfType().FirstOrDefault() is not { } reply) return images; + + Event? referenced = null; + if (bot.EventRepository is not null) + { + referenced = await bot.EventRepository.SearchEventAsync(@event.SelfId, + msg => msg.Data.PeerId == @event.Data.PeerId + && msg.Data.MessageSeq == reply.Data.MessageSeq, cancellationToken) + .FirstOrDefaultAsync(cancellationToken); + } + + referenced ??= await bot.GetHistoryMessageAsync(@event.SelfId, @event.Scene, @event.Data.PeerId, + reply.Data.MessageSeq, cancellationToken); + + if (referenced is null) return images; + + // 用被引用消息自身的事件取图:TempUrl 的有效期按被引用消息的时间判断,避免用当前消息的时间误判为新鲜。 + foreach (var segment in referenced.Data.Segments.OfType()) + { + if (images.Count >= max) break; + images.Add(await segment.GetMilkyImageBytesAsync(bot, referenced, cancellationToken)); + } + + return images; + } + + /// + /// 本条消息上下文中的图片:引用消息的图片在前、本消息的图片在后,最多 张。 + /// + public static async ValueTask> CollectContextImagesAsync(IBotContext bot, + Event @event, int max, CancellationToken cancellationToken) + { + var images = await CollectReferencedImagesAsync(bot, @event, max, cancellationToken); + if (max <= 0) return images; + + foreach (var segment in @event.Data.Segments.OfType()) + { + if (images.Count >= max) break; + images.Add(await segment.GetMilkyImageBytesAsync(bot, @event, cancellationToken)); + } + + return images; + } +} diff --git a/src/plugins/ZeroBot.Painter/PainterMaterialCommandHandler.cs b/src/plugins/ZeroBot.Painter/PainterMaterialCommandHandler.cs new file mode 100644 index 0000000..324d34c --- /dev/null +++ b/src/plugins/ZeroBot.Painter/PainterMaterialCommandHandler.cs @@ -0,0 +1,190 @@ +using Microsoft.Extensions.Logging; +using Milky.Net.Model; +using ZeroBot.Abstraction.Bot; +using ZeroBot.Abstraction.Service; +using ZeroBot.Utility; +using ZeroBot.Utility.FileWatcher; + +namespace ZeroBot.Painter; + +/// +/// 公共素材:/小画家:公共素材(列出)、/小画家:公共素材:添加:{name}(同名覆盖)、/小画家:公共素材:删除:{name}。 +/// 仅群聊,且本群必须已通过 /小画家:启用 开启(未开启的群完全静默,与 /小画家:画 一致)。 +/// 增 / 删需高权限(sudoers / 群管理员 / 拥有 painter.manage 权限者),列出对所有群员开放。 +/// 「添加」取本条消息上下文中的图片:优先引用消息中的图片,其次本消息中的图片,最多 MaxImages 张。 +/// +public class PainterMaterialCommandHandler( + ICommandDispatcher dispatcher, + IPermission permission, + IBotContext bot, + PainterMaterialStore store, + IJsonConfig config, + ILogger logger) : CommandHandler(dispatcher) +{ + private const string CommandPrefix = "/小画家:公共素材"; + private const string CommandPrefixFullWidth = "/小画家:公共素材"; + + /// 列表一次性最多展示的素材数,超出只提示总数。 + private const int MaxListed = 50; + + private static readonly OutgoingSegment HelpStrings = + ("/小画家:公共素材 - 列出本群公共素材\n" + + "/小画家:公共素材:添加:{name} - 把本条消息(或引用消息)中的图片添加为公共素材,同名覆盖\n" + + "/小画家:公共素材:删除:{name} - 删除本群公共素材").ToMilkyTextSegment(); + + /// + /// 解析 /小画家:公共素材[:{动作}[:{name}]]。 + /// 为空表示列出;name 为动作之后首个分隔符之后的全部原文 + /// (保留空格 / 连字符等,仅修剪首尾空白)。分隔符只认 : 与 :。 + /// + public static bool TryParse(string raw, out string action, out string name) + { + action = string.Empty; + name = string.Empty; + + string rest; + if (raw.StartsWith(CommandPrefix)) + { + rest = raw[CommandPrefix.Length..]; + } + else if (raw.StartsWith(CommandPrefixFullWidth)) + { + rest = raw[CommandPrefixFullWidth.Length..]; + } + else + { + return false; + } + + if (rest.Length == 0) return true; + if (rest[0] is not (':' or ':')) return false; + + rest = rest[1..]; + var separator = rest.IndexOfAny([':', ':']); + if (separator < 0) + { + action = rest.Trim(); + return true; + } + + action = rest[..separator].Trim(); + name = rest[(separator + 1)..].Trim(); + return true; + } + + protected override async ValueTask PredicateAsync(Event message, + CancellationToken cancellationToken = default) + { + if (message.Scene != MessageScene.Group) return false; + if (!TryParse(message.ToText().Trim(), out var action, out _)) return false; + + // 未开启绘图的群完全静默(不回复、不贴表情);与 /小画家:画 的谓词保持一致。 + if (!config.Current.Groups.ContainsKey(message.Data.PeerId)) return false; + + // 列出对所有群员开放;增删需高权限(无权限 → 谓词 false → 静默)。 + if (action is not ("添加" or "删除")) return true; + + return await permission.IsSudoerOrGroupAdminOrHasPermissionAsync(bot, message, + PainterManageCommandHandler.PermissionName, cancellationToken); + } + + protected override async ValueTask HandleAsync(Event message, + CancellationToken cancellationToken = default) + { + if (!TryParse(message.ToText().Trim(), out var action, out var name)) + { + await message.ReplyAsGroup(bot, cancellationToken, [HelpStrings]); + return; + } + + switch (action) + { + case "": + await ListAsync(message, cancellationToken); + return; + case "添加": + await AddAsync(message, name, cancellationToken); + return; + case "删除": + await DeleteAsync(message, name, cancellationToken); + return; + default: + await message.ReplyAsGroup(bot, cancellationToken, [HelpStrings]); + return; + } + } + + private async ValueTask ListAsync(Event message, CancellationToken cancellationToken) + { + var materials = store.List(message.Data.PeerId); + if (materials.Count == 0) + { + await message.ReplyAsGroup(bot, cancellationToken, ["本群暂无公共素材。".ToMilkyTextSegment()]); + return; + } + + var lines = new List { $"本群公共素材({materials.Count} 个):" }; + lines.AddRange(materials.Take(MaxListed).Select(m => $"· {m.Name}({m.Files.Count} 张)")); + if (materials.Count > MaxListed) + { + lines.Add($"……其余 {materials.Count - MaxListed} 个未显示"); + } + + await message.ReplyAsGroup(bot, cancellationToken, [string.Join('\n', lines).ToMilkyTextSegment()]); + } + + private async ValueTask AddAsync(Event message, string name, + CancellationToken cancellationToken) + { + if (!PainterMaterialStore.IsValidName(name, out var nameError)) + { + await message.ReplyAsGroup(bot, cancellationToken, + [$"添加失败:{nameError}".ToMilkyTextSegment()]); + return; + } + + var maxImages = config.Current.MaxImages; + List images; + try + { + images = await PainterImages.CollectContextImagesAsync(bot, message, maxImages, cancellationToken); + } + catch (Exception e) + { + logger.LogError(e, "添加公共素材时读取图片失败:peer={PeerId}", message.Data.PeerId); + await message.ReplyAsGroup(bot, cancellationToken, + ["添加失败:读取图片出错,请稍后重试。".ToMilkyTextSegment()]); + return; + } + + if (images.Count == 0) + { + await message.ReplyAsGroup(bot, cancellationToken, + ["没有找到可添加的图片:请随消息发送图片,或引用一条含图片的消息。".ToMilkyTextSegment()]); + return; + } + + var payload = images + .Select(bytes => (Bytes: bytes, Extension: PainterApi.DetectImageType(bytes).Extension)) + .ToList(); + + await store.AddAsync(message.Data.PeerId, name, payload, cancellationToken); + await message.ReplyAsGroup(bot, cancellationToken, + [$"已添加公共素材「{name}」({images.Count} 张图片)。".ToMilkyTextSegment()]); + } + + private async ValueTask DeleteAsync(Event message, string name, + CancellationToken cancellationToken) + { + if (!PainterMaterialStore.IsValidName(name, out var nameError)) + { + await message.ReplyAsGroup(bot, cancellationToken, + [$"删除失败:{nameError}".ToMilkyTextSegment()]); + return; + } + + var deleted = await store.DeleteAsync(message.Data.PeerId, name, cancellationToken); + await message.ReplyAsGroup(bot, cancellationToken, + [(deleted ? $"已删除公共素材「{name}」。" : $"未找到公共素材「{name}」。").ToMilkyTextSegment()]); + } +} diff --git a/src/plugins/ZeroBot.Painter/PainterMaterialStore.cs b/src/plugins/ZeroBot.Painter/PainterMaterialStore.cs new file mode 100644 index 0000000..37c5be6 --- /dev/null +++ b/src/plugins/ZeroBot.Painter/PainterMaterialStore.cs @@ -0,0 +1,207 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ZeroBot.Painter; + +/// +/// 公共素材:一个名称对应磁盘上的一个目录,目录内为若干按顺序编号的图片文件。 +/// +/// 素材名(用户书写原文)。 +/// 图片文件名(写入顺序)。 +public sealed record PainterMaterial(string Name, IReadOnlyList Files); + +/// +/// 群公共素材库,落盘于**工作目录**下: +/// +/// painter/{群uid}/{name_hash}/material.json # {"Name":"猫","Files":["0.png","1.jpg"]} +/// painter/{群uid}/{name_hash}/0.png +/// painter/{群uid}/{name_hash}/1.jpg +/// +/// 目录名取名称的 SHA-256 前 16 位十六进制,避免名称含路径分隔符 / 空白等非法字符; +/// 名称原文存于 material.json,用于列出与按名查找。 +/// 增删会在进程内串行(),并使用临时目录 + 原子替换实现覆盖。 +/// +public class PainterMaterialStore +{ + /// 素材名最大长度。 + public const int MaxNameLength = 32; + + private const string MetadataFileName = "material.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private readonly ILogger _logger; + private readonly string _root; + private readonly SemaphoreSlim _ioLock = new(1, 1); + + public PainterMaterialStore(ILogger logger) + : this(logger, Path.Combine(Environment.CurrentDirectory, "painter")) + { + } + + public PainterMaterialStore(ILogger logger, string root) + { + _logger = logger; + _root = root; + } + + /// 名称 → 目录名:SHA-256 前 16 位十六进制(小写)。 + public static string Hash(string name) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name)))[..16].ToLowerInvariant(); + + /// + /// 素材名合法性:非空、长度不超过 、不含分隔符(: / :)、 + /// 花括号(与提示词 {name} 引用语法冲突)或控制字符。 + /// + public static bool IsValidName(string name, out string? error) + { + error = null; + + if (string.IsNullOrWhiteSpace(name)) + { + error = "名称不能为空。"; + return false; + } + + if (name.Length > MaxNameLength) + { + error = $"名称不能超过 {MaxNameLength} 个字符。"; + return false; + } + + if (name.Any(c => c is ':' or ':' or '{' or '}' || char.IsControl(c))) + { + error = "名称不能包含冒号、花括号或换行。"; + return false; + } + + return true; + } + + /// 本群全部素材(按名称排序);目录缺失或元数据损坏的条目跳过。 + public IReadOnlyList List(long peerId) + { + var directory = GroupDirectory(peerId); + if (!Directory.Exists(directory)) return []; + + var materials = new List(); + foreach (var materialDirectory in Directory.EnumerateDirectories(directory)) + { + if (ReadMetadata(materialDirectory) is { } material) materials.Add(material); + } + + return materials.OrderBy(m => m.Name, StringComparer.Ordinal).ToList(); + } + + /// 按名称查找素材;不存在或元数据损坏返回 null。 + public PainterMaterial? Find(long peerId, string name) => ReadMetadata(MaterialDirectory(peerId, name)); + + /// 读取素材的全部图片字节(按写入顺序);文件缺失或读取失败时跳过该文件并记日志。 + public async ValueTask> LoadImagesAsync(long peerId, string name, + CancellationToken cancellationToken = default) + { + if (Find(peerId, name) is not { } material) return []; + + var directory = MaterialDirectory(peerId, name); + var images = new List(material.Files.Count); + foreach (var file in material.Files) + { + // 防御手改元数据造成的路径穿越。 + if (Path.GetFileName(file) != file) continue; + + var path = Path.Combine(directory, file); + try + { + if (File.Exists(path)) images.Add(await File.ReadAllBytesAsync(path, cancellationToken)); + } + catch (Exception e) + { + _logger.LogWarning(e, "读取公共素材图片失败:peer={PeerId} name={Name} file={File}", + peerId, name, file); + } + } + + return images; + } + + /// + /// 写入素材(同名覆盖):先写临时目录,成功后再原子替换旧目录,避免写入中途留下半成品。 + /// + public async ValueTask AddAsync(long peerId, string name, + IReadOnlyList<(byte[] Bytes, string Extension)> images, CancellationToken cancellationToken = default) + { + var target = MaterialDirectory(peerId, name); + var staging = $"{target}.tmp"; + + await _ioLock.WaitAsync(cancellationToken); + try + { + if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true); + Directory.CreateDirectory(staging); + + var files = new List(images.Count); + for (var i = 0; i < images.Count; i++) + { + var fileName = $"{i}.{images[i].Extension}"; + await File.WriteAllBytesAsync(Path.Combine(staging, fileName), images[i].Bytes, cancellationToken); + files.Add(fileName); + } + + var metadata = JsonSerializer.Serialize(new MaterialMetadata(name, files), JsonOptions); + await File.WriteAllTextAsync(Path.Combine(staging, MetadataFileName), metadata, cancellationToken); + + if (Directory.Exists(target)) Directory.Delete(target, recursive: true); + Directory.Move(staging, target); + } + finally + { + _ioLock.Release(); + } + } + + /// 删除素材;不存在返回 false。 + public async ValueTask DeleteAsync(long peerId, string name, + CancellationToken cancellationToken = default) + { + var target = MaterialDirectory(peerId, name); + + await _ioLock.WaitAsync(cancellationToken); + try + { + if (!Directory.Exists(target)) return false; + Directory.Delete(target, recursive: true); + return true; + } + finally + { + _ioLock.Release(); + } + } + + private string GroupDirectory(long peerId) => Path.Combine(_root, peerId.ToString()); + + private string MaterialDirectory(long peerId, string name) => Path.Combine(GroupDirectory(peerId), Hash(name)); + + private PainterMaterial? ReadMetadata(string directory) + { + try + { + var path = Path.Combine(directory, MetadataFileName); + if (!File.Exists(path)) return null; + + var metadata = JsonSerializer.Deserialize(File.ReadAllText(path)); + return string.IsNullOrWhiteSpace(metadata?.Name) + ? null + : new PainterMaterial(metadata.Name, metadata.Files ?? []); + } + catch (Exception e) + { + _logger.LogWarning(e, "读取公共素材元数据失败:{Directory}", directory); + return null; + } + } + + private sealed record MaterialMetadata(string Name, List? Files); +} diff --git a/src/plugins/ZeroBot.Painter/PainterPlugin.cs b/src/plugins/ZeroBot.Painter/PainterPlugin.cs index cabe104..909adcf 100644 --- a/src/plugins/ZeroBot.Painter/PainterPlugin.cs +++ b/src/plugins/ZeroBot.Painter/PainterPlugin.cs @@ -13,7 +13,9 @@ public ValueTask BuildComponents(CancellationToken cancellat services.ConfigureJsonConfig("painter-config.json", PainterOptions.Default, cancellationToken); services.AddSingleton(); + services.AddSingleton(); services.AddSingletonComponent(); + services.AddSingletonComponent(); services.AddSingletonComponent(); return ValueTask.FromResult(services); diff --git a/src/plugins/ZeroBot.Workflow/WorkflowCommandHandler.cs b/src/plugins/ZeroBot.Workflow/WorkflowCommandHandler.cs index 4841c97..7312158 100644 --- a/src/plugins/ZeroBot.Workflow/WorkflowCommandHandler.cs +++ b/src/plugins/ZeroBot.Workflow/WorkflowCommandHandler.cs @@ -4,12 +4,13 @@ using ZeroBot.Abstraction.Bot; using ZeroBot.Abstraction.Service; using ZeroBot.Utility; +using ZeroBot.Utility.FileWatcher; namespace ZeroBot.Workflow; /// /// 工作流指令:/workflow:run:{file|voice}:{script} -/// 仅群聊可用,且仅高权限用户(sudoers / 群管理员)可触发。 +/// 仅群聊可用,需所在群已通过 /workflow:enable 启用,且仅高权限用户(sudoers / 群管理员)可触发。 /// script 为指令第二个分隔符之后的全部内容(可换行)。 /// 执行流程:/v1/compile 校验展开 → /v1/run 执行 → 按 file/voice 发送结果;任一步失败均回复错误信息。 /// @@ -18,6 +19,7 @@ public class WorkflowCommandHandler( IBotContext bot, IPermission permission, IOptions options, + IJsonConfig groupConfig, WorkflowApi api, ILogger logger) : CommandQueuedHandler(dispatcher) { @@ -35,6 +37,8 @@ protected override async ValueTask PredicateAsync(Event m CancellationToken cancellationToken = default) { if (message.Scene != MessageScene.Group) return false; + // 未通过 /workflow:enable 启用的群不允许使用工作流。 + if (!groupConfig.Current.EnabledGroups.Contains(message.Data.PeerId)) return false; if (!IsRunCommand(message.ToText().TrimStart())) return false; return await permission.IsSudoerOrGroupAdminAsync(bot, message, cancellationToken); diff --git a/src/plugins/ZeroBot.Workflow/WorkflowGroupOptions.cs b/src/plugins/ZeroBot.Workflow/WorkflowGroupOptions.cs new file mode 100644 index 0000000..3c6be48 --- /dev/null +++ b/src/plugins/ZeroBot.Workflow/WorkflowGroupOptions.cs @@ -0,0 +1,16 @@ +namespace ZeroBot.Workflow; + +/// +/// 工作流群开关配置(热加载 workflow-config.json)。 +/// 磁盘字段名与属性名一致(PascalCase),手改配置时须使用 PascalCase。 +/// +public record WorkflowGroupOptions +{ + public static WorkflowGroupOptions Default => new(); + + /// + /// 已启用工作流功能的群 PeerId 集合;不在集合内的群不允许使用工作流。 + /// 由 /workflow:enable、/workflow:disable 维护。 + /// + public HashSet EnabledGroups { get; init; } = []; +} diff --git a/src/plugins/ZeroBot.Workflow/WorkflowManageCommandHandler.cs b/src/plugins/ZeroBot.Workflow/WorkflowManageCommandHandler.cs new file mode 100644 index 0000000..9ba0b19 --- /dev/null +++ b/src/plugins/ZeroBot.Workflow/WorkflowManageCommandHandler.cs @@ -0,0 +1,74 @@ +using Milky.Net.Model; +using ZeroBot.Abstraction.Bot; +using ZeroBot.Abstraction.Service; +using ZeroBot.Utility; +using ZeroBot.Utility.Commands; +using ZeroBot.Utility.FileWatcher; + +namespace ZeroBot.Workflow; + +/// +/// 工作流功能群开关:/workflow:enable 与 /workflow:disable。 +/// 仅群聊可用,且仅 bot 管理员(sudoers)可启用/禁用;群管理员无权操作。 +/// 启用时写入群开关配置,禁用时移除该群配置。 +/// +public class WorkflowManageCommandHandler( + ICommandDispatcher dispatcher, + IPermission permission, + IBotContext bot, + IJsonConfig config) : CommandHandler(dispatcher) +{ + private static readonly OutgoingSegment HelpStrings = + ("/workflow:enable\n" + + "/workflow:disable").ToMilkyTextSegment(); + + protected override async ValueTask PredicateAsync(Event message, + CancellationToken cancellationToken = default) + { + if (message.Scene != MessageScene.Group) return false; + + var command = message.ToTextCommands().FirstOrDefault(); + if (command is null || !command.Name.Equals("workflow", StringComparison.OrdinalIgnoreCase)) return false; + if (command.Arguments.FirstOrDefault() is not ("enable" or "disable")) return false; + + // 仅 bot 管理员(sudoers)可启用/禁用,群管理员无权操作。 + return await permission.IsSudoerAsync(message.Data.SenderId, cancellationToken); + } + + protected override async ValueTask HandleAsync(Event message, + CancellationToken cancellationToken = default) + { + var command = message.ToTextCommands().FirstOrDefault(); + var action = command?.Arguments.FirstOrDefault(); + var peerId = message.Data.PeerId; + + switch (action) + { + case "enable": + await config.BeginConfigMutationScopeAsync(async (value, token) => + { + value.EnabledGroups.Add(peerId); + await config.SaveAsync(value, token); + }, cancellationToken); + + await message.ReplyAsGroup(bot, cancellationToken, + ["已开启本群工作流功能。".ToMilkyTextSegment()]); + return; + + case "disable": + await config.BeginConfigMutationScopeAsync(async (value, token) => + { + value.EnabledGroups.Remove(peerId); + await config.SaveAsync(value, token); + }, cancellationToken); + + await message.ReplyAsGroup(bot, cancellationToken, + ["已关闭本群工作流功能。".ToMilkyTextSegment()]); + return; + + default: + await message.ReplyAsGroup(bot, cancellationToken, [HelpStrings]); + return; + } + } +} diff --git a/src/plugins/ZeroBot.Workflow/WorkflowOptions.cs b/src/plugins/ZeroBot.Workflow/WorkflowOptions.cs index b49383e..05617fc 100644 --- a/src/plugins/ZeroBot.Workflow/WorkflowOptions.cs +++ b/src/plugins/ZeroBot.Workflow/WorkflowOptions.cs @@ -13,7 +13,7 @@ public record WorkflowOptions public string BaseUrl { get; init; } = string.Empty; /// - /// 单次请求超时时间(秒),默认 600。 + /// 单次请求超时时间(秒),默认 1800(30 分钟)。 /// - public int HttpTimeoutSeconds { get; init; } = 600; + public int HttpTimeoutSeconds { get; init; } = 1800; } diff --git a/src/plugins/ZeroBot.Workflow/WorkflowPlugin.cs b/src/plugins/ZeroBot.Workflow/WorkflowPlugin.cs index 1381084..7d84aea 100644 --- a/src/plugins/ZeroBot.Workflow/WorkflowPlugin.cs +++ b/src/plugins/ZeroBot.Workflow/WorkflowPlugin.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZeroBot.Utility; +using ZeroBot.Utility.FileWatcher; namespace ZeroBot.Workflow; @@ -13,7 +14,10 @@ public ValueTask BuildComponents(CancellationToken cancellat // 配置节 WorkflowService,例如环境变量 WorkflowService__BaseUrl。 services.Configure(config.GetSection("WorkflowService")); + // 群开关(/workflow:enable、/workflow:disable 维护),热加载 workflow-config.json。 + services.ConfigureJsonConfig("workflow-config.json", WorkflowGroupOptions.Default, cancellationToken); services.AddSingleton(); + services.AddSingletonComponent(); services.AddSingletonComponent(); return ValueTask.FromResult(services); diff --git a/test/ZeroBot.Core.Test/PaintCommandHandlerTest.cs b/test/ZeroBot.Core.Test/PaintCommandHandlerTest.cs index 0467d54..98e5fda 100644 --- a/test/ZeroBot.Core.Test/PaintCommandHandlerTest.cs +++ b/test/ZeroBot.Core.Test/PaintCommandHandlerTest.cs @@ -40,8 +40,44 @@ public void TryParsePrompt_WithoutPrompt_ShouldMatchWithEmptyPrompt() [InlineData("/变毬")] [InlineData("/小画家:启用:3")] [InlineData("/小画家:禁用")] + [InlineData("/小画家:公共素材")] public void TryParsePrompt_ShouldNotMatchOtherCommands(string raw) { Assert.False(PaintCommandHandler.TryParsePrompt(raw, out _)); } + + [Fact] + public void SplitMaterialReferences_ShouldStripLeadingReferences() + { + var (references, remaining) = PaintCommandHandler.SplitMaterialReferences("{猫}{狗}一只猫和一只狗"); + Assert.Equal("{猫}", references[0].Raw); + Assert.Equal("猫", references[0].Name); + Assert.Equal("{狗}", references[1].Raw); + Assert.Equal("狗", references[1].Name); + Assert.Equal("一只猫和一只狗", remaining); + } + + [Fact] + public void SplitMaterialReferences_ShouldKeepNonReferencePrompt() + { + var (references, remaining) = PaintCommandHandler.SplitMaterialReferences("一只猫"); + Assert.Empty(references); + Assert.Equal("一只猫", remaining); + } + + [Fact] + public void SplitMaterialReferences_ShouldStopAtUnclosedBrace() + { + var (references, remaining) = PaintCommandHandler.SplitMaterialReferences("{猫}一只}猫"); + Assert.Single(references); + Assert.Equal("一只}猫", remaining); + } + + [Fact] + public void SplitMaterialReferences_EmptyBraces_ShouldNotBeReference() + { + var (references, remaining) = PaintCommandHandler.SplitMaterialReferences("{}一只猫"); + Assert.Empty(references); + Assert.Equal("{}一只猫", remaining); + } } diff --git a/test/ZeroBot.Core.Test/PainterApiTest.cs b/test/ZeroBot.Core.Test/PainterApiTest.cs index e34b1c7..e42400b 100644 --- a/test/ZeroBot.Core.Test/PainterApiTest.cs +++ b/test/ZeroBot.Core.Test/PainterApiTest.cs @@ -40,4 +40,27 @@ public void DetectImageType_ShouldFallbackForUnknown() var data = new byte[] { 0x00, 0x01, 0x02, 0x03 }; Assert.Equal(("application/octet-stream", "bin"), PainterApi.DetectImageType(data)); } + + [Fact] + public void NormalizeBody_ShouldTrimAndKeepJsonAsIs() + { + Assert.Equal("{\"detail\":\"出错了\"}", PainterApi.NormalizeBody(" {\"detail\":\"出错了\"}\n")); + } + + [Fact] + public void NormalizeBody_ShouldReturnNullForBlank() + { + Assert.Null(PainterApi.NormalizeBody(null)); + Assert.Null(PainterApi.NormalizeBody("")); + Assert.Null(PainterApi.NormalizeBody(" \n")); + } + + [Fact] + public void NormalizeBody_ShouldTruncateLongBody() + { + var body = new string('a', 400); + var result = PainterApi.NormalizeBody(body); + Assert.NotNull(result); + Assert.Equal(new string('a', 300) + "…", result); + } } diff --git a/test/ZeroBot.Core.Test/PainterMaterialCommandHandlerTest.cs b/test/ZeroBot.Core.Test/PainterMaterialCommandHandlerTest.cs new file mode 100644 index 0000000..90922d0 --- /dev/null +++ b/test/ZeroBot.Core.Test/PainterMaterialCommandHandlerTest.cs @@ -0,0 +1,55 @@ +using ZeroBot.Painter; + +namespace ZeroBot.Core.Test; + +public class PainterMaterialCommandHandlerTest +{ + [Fact] + public void TryParse_ListWithoutAction() + { + Assert.True(PainterMaterialCommandHandler.TryParse("/小画家:公共素材", out var action, out var name)); + Assert.Equal(string.Empty, action); + Assert.Equal(string.Empty, name); + } + + [Fact] + public void TryParse_Add() + { + Assert.True(PainterMaterialCommandHandler.TryParse("/小画家:公共素材:添加:猫", out var action, out var name)); + Assert.Equal("添加", action); + Assert.Equal("猫", name); + } + + [Fact] + public void TryParse_DeleteWithFullWidthSeparator() + { + Assert.True(PainterMaterialCommandHandler.TryParse("/小画家:公共素材:删除:猫", out var action, out var name)); + Assert.Equal("删除", action); + Assert.Equal("猫", name); + } + + [Fact] + public void TryParse_NameShouldPreserveSpacesAndHyphen() + { + Assert.True(PainterMaterialCommandHandler.TryParse("/小画家:公共素材:添加:my cat-1", out _, out var name)); + Assert.Equal("my cat-1", name); + } + + [Fact] + public void TryParse_AddWithoutName() + { + Assert.True(PainterMaterialCommandHandler.TryParse("/小画家:公共素材:添加", out var action, out var name)); + Assert.Equal("添加", action); + Assert.Equal(string.Empty, name); + } + + [Theory] + [InlineData("/小画家:公共素材册:x")] + [InlineData("/小画家:画:一只猫")] + [InlineData("/小画家:启用:3")] + [InlineData("/小画家:画")] + public void TryParse_ShouldNotMatchOtherCommands(string raw) + { + Assert.False(PainterMaterialCommandHandler.TryParse(raw, out _, out _)); + } +} diff --git a/test/ZeroBot.Core.Test/PainterMaterialStoreTest.cs b/test/ZeroBot.Core.Test/PainterMaterialStoreTest.cs new file mode 100644 index 0000000..2a9ceb4 --- /dev/null +++ b/test/ZeroBot.Core.Test/PainterMaterialStoreTest.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ZeroBot.Painter; + +namespace ZeroBot.Core.Test; + +public class PainterMaterialStoreTest : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"painter-material-test-{Guid.NewGuid():N}"); + private readonly PainterMaterialStore _store; + + public PainterMaterialStoreTest() + { + _store = new PainterMaterialStore(NullLogger.Instance, _root); + } + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } + + [Fact] + public void Hash_ShouldBeDeterministicHex16() + { + var hash = PainterMaterialStore.Hash("猫"); + Assert.Equal(16, hash.Length); + Assert.Equal(hash, PainterMaterialStore.Hash("猫")); + Assert.NotEqual(hash, PainterMaterialStore.Hash("狗")); + Assert.All(hash, c => Assert.Contains(c, "0123456789abcdef")); + } + + [Theory] + [InlineData("猫", true)] + [InlineData("my cat-1", true)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("a:cat", false)] + [InlineData("a:cat", false)] + [InlineData("{cat}", false)] + [InlineData("a\nb", false)] + public void IsValidName_ShouldValidate(string name, bool expected) + { + Assert.Equal(expected, PainterMaterialStore.IsValidName(name, out _)); + } + + [Fact] + public void IsValidName_TooLong_ShouldFail() + { + Assert.False(PainterMaterialStore.IsValidName(new string('a', PainterMaterialStore.MaxNameLength + 1), out var error)); + Assert.NotNull(error); + } + + [Fact] + public async Task Add_Find_List_ShouldWork() + { + await _store.AddAsync(100, "猫", [(new byte[] { 1, 2 }, "png")]); + await _store.AddAsync(100, "狗", [(new byte[] { 3 }, "jpg"), (new byte[] { 4 }, "png")]); + + Assert.NotNull(_store.Find(100, "猫")); + Assert.Null(_store.Find(100, "鸟")); + + var materials = _store.List(100); + Assert.Equal(new[] { "狗", "猫" }, materials.Select(m => m.Name).OrderBy(n => n, StringComparer.Ordinal)); + Assert.Equal(2, materials.Single(m => m.Name == "狗").Files.Count); + } + + [Fact] + public async Task Add_SameName_ShouldOverwrite() + { + await _store.AddAsync(100, "猫", [(new byte[] { 1 }, "png"), (new byte[] { 2 }, "png")]); + await _store.AddAsync(100, "猫", [(new byte[] { 9 }, "jpg")]); + + var images = await _store.LoadImagesAsync(100, "猫"); + Assert.Single(images); + Assert.Equal(new byte[] { 9 }, images[0]); + } + + [Fact] + public async Task LoadImages_ShouldKeepWriteOrder() + { + await _store.AddAsync(100, "猫", [(new byte[] { 1 }, "png"), (new byte[] { 2 }, "jpg")]); + + var images = await _store.LoadImagesAsync(100, "猫"); + Assert.Equal(2, images.Count); + Assert.Equal(new byte[] { 1 }, images[0]); + Assert.Equal(new byte[] { 2 }, images[1]); + } + + [Fact] + public async Task Delete_ShouldRemoveMaterial() + { + await _store.AddAsync(100, "猫", [(new byte[] { 1 }, "png")]); + + Assert.True(await _store.DeleteAsync(100, "猫")); + Assert.False(await _store.DeleteAsync(100, "猫")); + Assert.Null(_store.Find(100, "猫")); + Assert.Empty(_store.List(100)); + } + + [Fact] + public async Task Materials_ShouldBeIsolatedPerGroup() + { + await _store.AddAsync(100, "猫", [(new byte[] { 1 }, "png")]); + + Assert.Null(_store.Find(200, "猫")); + Assert.Empty(_store.List(200)); + } + + [Fact] + public void List_WithoutDirectory_ShouldReturnEmpty() + { + Assert.Empty(_store.List(999)); + Assert.Null(_store.Find(999, "猫")); + } +}