Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/plugins/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ TypedPluginLoader.Register<MyPlugin>();

**命令:**
- `/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`)

Expand Down
71 changes: 69 additions & 2 deletions src/plugins/ZeroBot.Synthesize/DatasetAliasCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
namespace ZeroBot.Synthesize;

/// <summary>
/// 数据集别名管理:/synthesize:dataset:{dataset-id}:{alias}
/// 兼容 /synthesize:{dataset-id}:{alias}。
/// 数据集别名与额度管理:/synthesize:dataset:{dataset-id}:{alias}
/// 兼容 /synthesize:{dataset-id}:{alias};另支持 /synthesize:limit:{number} 设置本群每人每日上限。
/// 仅高权限用户(sudoers / 群管理员 / 拥有 synthesize.alias 权限者)可用。
/// </summary>
public class DatasetAliasCommandHandler(
Expand All @@ -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<bool> PredicateAsync(Event<IncomingMessage> message,
CancellationToken cancellationToken = default)
{
Expand All @@ -31,11 +34,19 @@ protected override async ValueTask<bool> PredicateAsync(Event<IncomingMessage> 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<IncomingMessage> 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)
{
Expand All @@ -59,6 +70,62 @@ await message.Reply(bot, token,
}, cancellationToken);
}

/// <summary>
/// 解析 /synthesize:limit:{number} 的数字部分(保留负号,避免被命令分隔符拆开)。
/// </summary>
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<IncomingMessage> 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
Expand Down
24 changes: 18 additions & 6 deletions src/plugins/ZeroBot.Synthesize/SynthesizeCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ namespace ZeroBot.Synthesize;
/// <summary>
/// 语音合成:/学:{alias}:{text}
/// 通过别名解析 dataset-id,调用合成接口并把音频发送到群聊。
/// 仅群聊可用,任何用户均可使用;同群同人每天最多生成 3 条(UTC+8 0 点刷新)。
/// 仅群聊可用,任何用户均可使用;同群同人每天最多生成若干条(UTC+8 0 点刷新),
/// 上限由 `/synthesize:limit:{number}` 按群设置,未设置时默认为 3 条。
/// </summary>
public class SynthesizeCommandHandler(
ICommandDispatcher dispatcher,
Expand Down Expand Up @@ -45,17 +46,28 @@ protected override async ValueTask HandleAsync(Event<IncomingMessage> 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;
Expand Down Expand Up @@ -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<bool> TryConsumeQuotaAsync(long peerId, long senderId,
private async ValueTask<bool> TryConsumeQuotaAsync(long peerId, long senderId, int limit,
CancellationToken cancellationToken)
{
var key = SynthesizeQuota.Key(peerId, senderId);
Expand All @@ -100,7 +112,7 @@ private async ValueTask<bool> 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);
Expand Down
18 changes: 16 additions & 2 deletions src/plugins/ZeroBot.Synthesize/SynthesizeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,29 @@ public record SynthesizeOptions
{
public const string DefaultEndpoint = "http://z-vtuber-training.vtuber.svc.cluster.local:8080";

/// <summary>
/// 群未单独配置时使用的默认每日上限,默认为 3。
/// -1 表示不限制;0 表示不允许;&gt;0 表示上限。
/// </summary>
public const int DefaultDailyLimit = 3;

/// <summary>
/// 合成服务地址,支持热加载动态修改。
/// </summary>
public string Endpoint { get; init; } = DefaultEndpoint;

/// <summary>
/// 每个群聊 + 发送人每天的生成上限。
/// 每个群聊 + 发送人每天的默认生成上限(群未单独配置时使用,默认 3)。
/// -1 表示不限制;0 表示不允许;&gt;0 表示上限。
/// </summary>
public int DailyLimit { get; init; } = DefaultDailyLimit;

/// <summary>
/// 每个群聊单独配置的每日生成上限(key 为群 PeerId),由
/// <c>/synthesize:limit:{number}</c> 指令设置,未配置的群回落到 <see cref="DailyLimit"/>。
/// 语义同 <see cref="DailyLimit"/>:-1 不限制;0 不允许;&gt;0 为上限。
/// </summary>
public int DailyLimit { get; init; } = 3;
public Dictionary<long, int> GroupDailyLimits { get; init; } = [];

/// <summary>
/// 合成语言。
Expand Down
12 changes: 12 additions & 0 deletions src/plugins/ZeroBot.Synthesize/SynthesizeQuota.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,16 @@ public static DateOnly Today(DateTimeOffset now) =>
/// 额度 key:群聊 PeerId + 发送人 SenderId。
/// </summary>
public static string Key(long peerId, long senderId) => $"{peerId}:{senderId}";

/// <summary>
/// 解析指定群的有效每日上限:群单独配置优先,未配置时回落到默认值。
/// </summary>
public static int ResolveDailyLimit(IReadOnlyDictionary<long, int> groupLimits, long peerId, int defaultLimit) =>
groupLimits.TryGetValue(peerId, out var limit) ? limit : defaultLimit;

/// <summary>
/// 判断在给定上限下还能否继续生成:
/// limit &lt; 0 表示不限制;limit == 0 表示不允许;limit &gt; 0 表示需 count &lt; limit。
/// </summary>
public static bool CanConsume(int limit, int count) => limit < 0 || count < limit;
}
26 changes: 26 additions & 0 deletions test/ZeroBot.Core.Test/SynthesizeQuotaTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<long, int> { [111] = 7 };
Assert.Equal(7, SynthesizeQuota.ResolveDailyLimit(groupLimits, 111, 3));
}

[Fact]
public void ResolveDailyLimit_ShouldFallbackToDefaultWhenUnset()
{
var groupLimits = new Dictionary<long, int> { [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));
}
}
Loading