diff --git a/src/plugins/AGENTS.md b/src/plugins/AGENTS.md index 9789a93..3dd35d4 100644 --- a/src/plugins/AGENTS.md +++ b/src/plugins/AGENTS.md @@ -309,7 +309,8 @@ TypedPluginLoader.Register(); **命令:** - `/synthesize:dataset:{dataset-id}:{alias}` - 绑定数据集别名(高权限用户,兼容 `/synthesize:{dataset-id}:{alias}`) -- `/学:{alias}:{text}` - 通过别名合成语音并发送到群聊(仅群聊,同群同人每天 3 条) +- `/synthesize:limit:{number}` - 设置本群每人每日生成上限(高权限用户;-1 不限制,0 禁止,>0 为上限;未设置默认 3) +- `/学:{alias}:{text}` - 通过别名合成语音并发送到群聊(仅群聊,同群同人每天受该群上限限制,默认 3 条) **配置文件:** `synthesize-config.json`(`endpoint` 支持热加载,默认可用环境变量 `Z_VTUBER_TRAINING_ENDPOINT`) diff --git a/src/plugins/ZeroBot.Synthesize/DatasetAliasCommandHandler.cs b/src/plugins/ZeroBot.Synthesize/DatasetAliasCommandHandler.cs index 4af602f..062a2e3 100644 --- a/src/plugins/ZeroBot.Synthesize/DatasetAliasCommandHandler.cs +++ b/src/plugins/ZeroBot.Synthesize/DatasetAliasCommandHandler.cs @@ -8,8 +8,8 @@ namespace ZeroBot.Synthesize; /// -/// 数据集别名管理:/synthesize:dataset:{dataset-id}:{alias} -/// 兼容 /synthesize:{dataset-id}:{alias}。 +/// 数据集别名与额度管理:/synthesize:dataset:{dataset-id}:{alias} +/// 兼容 /synthesize:{dataset-id}:{alias};另支持 /synthesize:limit:{number} 设置本群每人每日上限。 /// 仅高权限用户(sudoers / 群管理员 / 拥有 synthesize.alias 权限者)可用。 /// public class DatasetAliasCommandHandler( @@ -20,6 +20,9 @@ public class DatasetAliasCommandHandler( { public const string PermissionName = "synthesize.alias"; + private const string LimitPrefix = "/synthesize:limit:"; + private const string LimitPrefixFullWidth = "/synthesize:limit:"; + protected override async ValueTask PredicateAsync(Event message, CancellationToken cancellationToken = default) { @@ -31,11 +34,19 @@ protected override async ValueTask PredicateAsync(Event m private static readonly OutgoingSegment HelpStrings = ("/synthesize:dataset:{dataset-id}:{alias}\n" + + "/synthesize:limit:{number}(-1 不限制,0 禁止,>0 为每人每日上限)\n" + "示例:/synthesize:dataset:6aba45e18826119a12d738a4:小松绿").ToMilkyTextSegment(); protected override async ValueTask HandleAsync(Event message, CancellationToken cancellationToken = default) { + var raw = message.ToText().Trim(); + if (TryGetLimitArgument(raw, out var limitValue)) + { + await HandleLimitAsync(message, limitValue, cancellationToken); + return; + } + var command = message.Data.ToTextCommands().FirstOrDefault(); if (command is null) { @@ -59,6 +70,62 @@ await message.Reply(bot, token, }, cancellationToken); } + /// + /// 解析 /synthesize:limit:{number} 的数字部分(保留负号,避免被命令分隔符拆开)。 + /// + private static bool TryGetLimitArgument(string raw, out string value) + { + value = string.Empty; + if (raw.StartsWith(LimitPrefix)) + { + value = raw[LimitPrefix.Length..]; + return true; + } + + if (raw.StartsWith(LimitPrefixFullWidth)) + { + value = raw[LimitPrefixFullWidth.Length..]; + return true; + } + + return false; + } + + private async ValueTask HandleLimitAsync(Event message, string rawValue, + CancellationToken cancellationToken) + { + if (message.Scene != MessageScene.Group) + { + await message.Reply(bot, cancellationToken, + ["该指令仅可在群聊中使用。".ToMilkyTextSegment()]); + return; + } + + if (!int.TryParse(rawValue.Trim(), out var limit)) + { + await message.Reply(bot, cancellationToken, + ["参数无效,请输入整数:/synthesize:limit:{number}(-1 不限制,0 禁止,>0 为上限)" + .ToMilkyTextSegment()]); + return; + } + + var peerId = message.Data.PeerId; + await config.BeginConfigMutationScopeAsync(async (value, token) => + { + value.GroupDailyLimits[peerId] = limit; + await config.SaveAsync(value, token); + + var description = limit switch + { + < 0 => "不限制", + 0 => "禁止生成", + _ => $"每人每天最多 {limit} 条" + }; + await message.Reply(bot, token, + [$"本群语音合成额度已设置:{description}。".ToMilkyTextSegment()]); + }, cancellationToken); + } + private static (string? DatasetId, string? Alias) Parse(string[] arguments) { return arguments switch diff --git a/src/plugins/ZeroBot.Synthesize/SynthesizeCommandHandler.cs b/src/plugins/ZeroBot.Synthesize/SynthesizeCommandHandler.cs index d723432..aef0bd6 100644 --- a/src/plugins/ZeroBot.Synthesize/SynthesizeCommandHandler.cs +++ b/src/plugins/ZeroBot.Synthesize/SynthesizeCommandHandler.cs @@ -8,7 +8,8 @@ namespace ZeroBot.Synthesize; /// /// 语音合成:/学:{alias}:{text} /// 通过别名解析 dataset-id,调用合成接口并把音频发送到群聊。 -/// 仅群聊可用,任何用户均可使用;同群同人每天最多生成 3 条(UTC+8 0 点刷新)。 +/// 仅群聊可用,任何用户均可使用;同群同人每天最多生成若干条(UTC+8 0 点刷新), +/// 上限由 `/synthesize:limit:{number}` 按群设置,未设置时默认为 3 条。 /// public class SynthesizeCommandHandler( ICommandDispatcher dispatcher, @@ -45,17 +46,28 @@ protected override async ValueTask HandleAsync(Event message, return; } - if (!await TryConsumeQuotaAsync(message.Data.PeerId, message.Data.SenderId, cancellationToken)) + var limit = SynthesizeQuota.ResolveDailyLimit(options.GroupDailyLimits, message.Data.PeerId, + options.DailyLimit); + if (limit == 0) { await message.ReplyAsGroup(bot, cancellationToken, - [$"今日生成次数已用完(上限 {options.DailyLimit} 条/天),请明天再试。".ToMilkyTextSegment()]); + ["本群未开放语音合成功能。".ToMilkyTextSegment()]); + return; + } + + // limit < 0 表示不限制,无需消耗额度。 + var limited = limit > 0; + if (limited && !await TryConsumeQuotaAsync(message.Data.PeerId, message.Data.SenderId, limit, cancellationToken)) + { + await message.ReplyAsGroup(bot, cancellationToken, + [$"今日生成次数已用完(上限 {limit} 条/天),请明天再试。".ToMilkyTextSegment()]); return; } var bytes = await api.SynthesizeAsync(options.Endpoint, datasetId, text, options.Lang, cancellationToken); if (bytes is null) { - await ReleaseQuotaAsync(message.Data.PeerId, message.Data.SenderId, cancellationToken); + if (limited) await ReleaseQuotaAsync(message.Data.PeerId, message.Data.SenderId, cancellationToken); await message.ReplyAsGroup(bot, cancellationToken, ["语音合成失败,请稍后重试。".ToMilkyTextSegment()]); return; @@ -88,7 +100,7 @@ private static bool TryParse(string raw, out string alias, out string text) return alias.Length > 0 && text.Length > 0; } - private async ValueTask TryConsumeQuotaAsync(long peerId, long senderId, + private async ValueTask TryConsumeQuotaAsync(long peerId, long senderId, int limit, CancellationToken cancellationToken) { var key = SynthesizeQuota.Key(peerId, senderId); @@ -100,7 +112,7 @@ private async ValueTask TryConsumeQuotaAsync(long peerId, long senderId, value.DailyQuotas[key] = quota = new DailyQuota(today, 0); } - if (quota.Count >= value.DailyLimit) return false; + if (!SynthesizeQuota.CanConsume(limit, quota.Count)) return false; value.DailyQuotas[key] = quota with { Count = quota.Count + 1 }; await config.SaveAsync(value, token); diff --git a/src/plugins/ZeroBot.Synthesize/SynthesizeOptions.cs b/src/plugins/ZeroBot.Synthesize/SynthesizeOptions.cs index a7cf975..63c194f 100644 --- a/src/plugins/ZeroBot.Synthesize/SynthesizeOptions.cs +++ b/src/plugins/ZeroBot.Synthesize/SynthesizeOptions.cs @@ -12,15 +12,29 @@ public record SynthesizeOptions { public const string DefaultEndpoint = "http://z-vtuber-training.vtuber.svc.cluster.local:8080"; + /// + /// 群未单独配置时使用的默认每日上限,默认为 3。 + /// -1 表示不限制;0 表示不允许;>0 表示上限。 + /// + public const int DefaultDailyLimit = 3; + /// /// 合成服务地址,支持热加载动态修改。 /// public string Endpoint { get; init; } = DefaultEndpoint; /// - /// 每个群聊 + 发送人每天的生成上限。 + /// 每个群聊 + 发送人每天的默认生成上限(群未单独配置时使用,默认 3)。 + /// -1 表示不限制;0 表示不允许;>0 表示上限。 + /// + public int DailyLimit { get; init; } = DefaultDailyLimit; + + /// + /// 每个群聊单独配置的每日生成上限(key 为群 PeerId),由 + /// /synthesize:limit:{number} 指令设置,未配置的群回落到 。 + /// 语义同 :-1 不限制;0 不允许;>0 为上限。 /// - public int DailyLimit { get; init; } = 3; + public Dictionary GroupDailyLimits { get; init; } = []; /// /// 合成语言。 diff --git a/src/plugins/ZeroBot.Synthesize/SynthesizeQuota.cs b/src/plugins/ZeroBot.Synthesize/SynthesizeQuota.cs index e89648b..df0b0cb 100644 --- a/src/plugins/ZeroBot.Synthesize/SynthesizeQuota.cs +++ b/src/plugins/ZeroBot.Synthesize/SynthesizeQuota.cs @@ -20,4 +20,16 @@ public static DateOnly Today(DateTimeOffset now) => /// 额度 key:群聊 PeerId + 发送人 SenderId。 /// public static string Key(long peerId, long senderId) => $"{peerId}:{senderId}"; + + /// + /// 解析指定群的有效每日上限:群单独配置优先,未配置时回落到默认值。 + /// + public static int ResolveDailyLimit(IReadOnlyDictionary groupLimits, long peerId, int defaultLimit) => + groupLimits.TryGetValue(peerId, out var limit) ? limit : defaultLimit; + + /// + /// 判断在给定上限下还能否继续生成: + /// limit < 0 表示不限制;limit == 0 表示不允许;limit > 0 表示需 count < limit。 + /// + public static bool CanConsume(int limit, int count) => limit < 0 || count < limit; } diff --git a/test/ZeroBot.Core.Test/SynthesizeQuotaTest.cs b/test/ZeroBot.Core.Test/SynthesizeQuotaTest.cs index ac6f202..02bcf59 100644 --- a/test/ZeroBot.Core.Test/SynthesizeQuotaTest.cs +++ b/test/ZeroBot.Core.Test/SynthesizeQuotaTest.cs @@ -23,4 +23,30 @@ public void Key_ShouldCombinePeerAndSender() { Assert.Equal("123:456", SynthesizeQuota.Key(123, 456)); } + + [Fact] + public void ResolveDailyLimit_ShouldPreferGroupSetting() + { + var groupLimits = new Dictionary { [111] = 7 }; + Assert.Equal(7, SynthesizeQuota.ResolveDailyLimit(groupLimits, 111, 3)); + } + + [Fact] + public void ResolveDailyLimit_ShouldFallbackToDefaultWhenUnset() + { + var groupLimits = new Dictionary { [111] = 7 }; + Assert.Equal(3, SynthesizeQuota.ResolveDailyLimit(groupLimits, 222, 3)); + } + + [Theory] + [InlineData(-1, 0, true)] + [InlineData(-1, 100, true)] + [InlineData(0, 0, false)] + [InlineData(3, 0, true)] + [InlineData(3, 2, true)] + [InlineData(3, 3, false)] + public void CanConsume_ShouldRespectLimitSemantics(int limit, int count, bool expected) + { + Assert.Equal(expected, SynthesizeQuota.CanConsume(limit, count)); + } }