diff --git a/deploy/flue-bridge/README.md b/deploy/flue-bridge/README.md index 94d528f..128a694 100644 --- a/deploy/flue-bridge/README.md +++ b/deploy/flue-bridge/README.md @@ -56,7 +56,7 @@ signed bytes = UTF8("v1:" + timestamp + ":") || exact HTTP body bytes The Worker accepts timestamps within 300 seconds and uses `eventId` as Flue's durable dispatch idempotency key. Timestamp is included in the MAC to prevent changing the replay window. Signed requests do not follow redirects. -Replies are JSON strings published with Queue `contentType: 'text'`; `json`/`bytes` content would be Base64-encoded by HTTP pull. The consumer validates the version, destination allowlist, Markdown format, UTF-8 size, and the conversation mapping of `replyToMessageId` before sending. Invalid jobs are acknowledged as poison. Adapter failures explicitly retry with delay. Successful sends atomically save all returned message IDs and the sent ledger before acknowledgment. +Replies are JSON strings published with Queue `contentType: 'text'`; `json`/`bytes` content would be Base64-encoded by HTTP pull. The consumer validates the version, destination allowlist, Markdown format, UTF-8 size, and the conversation mapping of `replyToMessageId` before sending. Invalid jobs are acknowledged as poison. Adapter failures explicitly retry with delay. Successful sends atomically save all returned message IDs and the sent ledger before acknowledgment.\n\nNatural-language command requests are a separate Queue job shape: kind: command carries a typed command.id, scalar command.arguments, and the original sender ID. The bot resolves the command from its runtime catalog, applies admin/group/cooldown checks, and sends the command result through the normal adapter. Command jobs use the same destination and conversation mapping validation as replies, and are deduplicated by jobId. The agent's summary command remains on the Worker Workflow rather than being sent back to the bot. Cloudflare HTTP pull's `visibility_timeout` is expressed in milliseconds; the plugin setting is in seconds. The plugin pulls one lease per batch to avoid expiring later leases while adapters render/send an earlier reply. It polls every second during the active window, gradually backs off to the idle interval, and wakes immediately after accepted ingress. diff --git a/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs b/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs index 015ec20..da20293 100644 --- a/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs @@ -170,7 +170,7 @@ public async Task CalendarCommandAsync(GroupMessageEventArgs e) [Command("日程", "查看近期日程", Alias = "schedule")] // ReSharper disable once UnusedMember.Global public async Task CalendarCommandAsync( - [CommandArgumentString("时间")] string? content, + [CommandArgumentString("时间")] string? weeks, GroupMessageEventArgs e ) { @@ -179,9 +179,9 @@ GroupMessageEventArgs e return; var now = Utils.NetworkTime.Now; var week = 1; - if (!string.IsNullOrWhiteSpace(content)) //参数不为空 + if (!string.IsNullOrWhiteSpace(weeks)) //参数不为空 { - if (!int.TryParse(content, out week)) //尝试转换为数字表示周数 + if (!int.TryParse(weeks, out week)) //尝试转换为数字表示周数 { await e.Reply("参数错误"); return; diff --git a/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs b/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs index 5222ade..7a42f6c 100644 --- a/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using HuaJiBot.NET.AI; using HuaJiBot.NET.Bot; using HuaJiBot.NET.Commands; @@ -147,7 +147,7 @@ [new ChatMessage(ChatRole.User, userMessage)], // ReSharper disable once UnusedMember.Global public async Task SummaryCommand( GroupMessageEventArgs e, - [CommandArgumentEnum("日期")] SummaryTarget? target = null + [CommandArgumentEnum("日期")] SummaryTarget? day = null ) { if (!Config.GroupIds.Contains(e.GroupId)) @@ -162,7 +162,7 @@ public async Task SummaryCommand( _cooldown[e.SenderId] = now; _ = Task.Delay(Cooldown).ContinueWith(_ => _cooldown.TryRemove(e.SenderId, out var _)); - var date = now.Date.AddDays(-(int)(target ?? SummaryTarget.昨天)); + var date = now.Date.AddDays(-(int)(day ?? SummaryTarget.昨天)); await e.Reply($"收到,正在总结 {date:yyyy-MM-dd} 的聊天记录…"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(Config.LlmTimeoutSeconds)); try diff --git a/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeEngine.cs b/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeEngine.cs index e36629e..bc596b5 100644 --- a/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeEngine.cs +++ b/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeEngine.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.Text.Json; using HuaJiBot.NET.Bot; using HuaJiBot.NET.Events; @@ -7,7 +7,7 @@ namespace HuaJiBot.NET.Plugin.FlueBridge; internal sealed class BridgeEngine(PluginConfig config, BridgeStore store, IPluginService service, - IReplyQueue queue, Action warn) + IReplyQueue queue, Action warn, Func? commandExecutor = null) { internal IngressEvent? Normalize(GroupMessageEventArgs e) { @@ -69,8 +69,51 @@ private bool IsValid(ReplyJob? job) return store.Find(job.Destination.RobotId, job.Destination.GroupId, job.Destination.ReplyToMessageId) == job.ConversationId; } + private bool IsValid(CommandJob? job) + { + if (job is not { Version: 2, Destination: not null, Command: not null } + || string.IsNullOrWhiteSpace(job.JobId) || job.JobId.Length > 200 + || string.IsNullOrWhiteSpace(job.ConversationId) || job.CreatedAt == default + || job.Destination.BridgeInstance != config.BridgeInstance + || !config.Allows(job.Destination.RobotId, job.Destination.GroupId) + || string.IsNullOrWhiteSpace(job.SenderId) + || string.IsNullOrWhiteSpace(job.Destination.ReplyToMessageId) + || job.Kind != "command" + || string.IsNullOrWhiteSpace(job.Command.Id) + || job.Command.Arguments.ValueKind != JsonValueKind.Object) return false; + var descriptor = service.CommandService?.GetCommandCatalog() + .FirstOrDefault(x => string.Equals(x.Id, job.Command.Id, StringComparison.OrdinalIgnoreCase)); + if (descriptor is null) return false; + return store.Find(job.Destination.RobotId, job.Destination.GroupId, job.Destination.ReplyToMessageId) == job.ConversationId; + } + + private static string? JobKind(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("kind", out var kind) && kind.ValueKind == JsonValueKind.String + ? kind.GetString() + : "chat"; + } + catch (JsonException) { return null; } + } + internal async Task DeliverAsync(QueueLease lease, CancellationToken ct) { + if (Encoding.UTF8.GetByteCount(lease.Body) > config.MaxMessageBytes) + { + warn("Discarding oversized Queue job."); + await queue.AckAsync(lease.LeaseId, ct); + return; + } + var kind = JobKind(lease.Body); + if (kind == "command") + { + await DeliverCommandAsync(lease, ct); + return; + } + ReplyJob? job = null; if (Encoding.UTF8.GetByteCount(lease.Body) <= config.MaxMessageBytes) { @@ -116,4 +159,48 @@ internal async Task DeliverAsync(QueueLease lease, CancellationToken ct) // Keep ack failures outside the send catch. A later pull sees the durable sent ledger. await queue.AckAsync(lease.LeaseId, ct); } -} + + private async Task DeliverCommandAsync(QueueLease lease, CancellationToken ct) + { + CommandJob? job = null; + if (Encoding.UTF8.GetByteCount(lease.Body) <= config.MaxMessageBytes) + { + try { job = JsonSerializer.Deserialize(lease.Body, BridgeJson.Options); } + catch (JsonException) { } + } + if (job is { Version: 2 } && !string.IsNullOrWhiteSpace(job.JobId) && store.WasSent(job.JobId)) + { + await queue.AckAsync(lease.LeaseId, ct); + return; + } + if (commandExecutor is null || !IsValid(job)) + { + warn("Discarding malformed or unauthorized command job."); + await queue.AckAsync(lease.LeaseId, ct); + return; + } + + try + { + ct.ThrowIfCancellationRequested(); + await commandExecutor(job!, ct); + store.CompleteCommand(job!); + } + catch (ArgumentException ex) + { + warn($"Command rejected: {ex.Message}"); + var content = new RichContent($"命令参数无效:{ex.Message}", job!.Destination.ReplyToMessageId); + await service.SendRichMessageAsync(job!.Destination.RobotId, job.Destination.GroupId, content, + () => Task.FromResult([content.ToPlainText()])); + store.CompleteCommand(job); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch + { + warn("Command execution failed; retrying Queue lease."); + await queue.RetryAsync(lease.LeaseId, ct); + return; + } + await queue.AckAsync(lease.LeaseId, ct); + } +} \ No newline at end of file diff --git a/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeStore.cs b/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeStore.cs index 08e361d..5c4f2b6 100644 --- a/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeStore.cs +++ b/src/HuaJiBot.NET.Plugin.FlueBridge/BridgeStore.cs @@ -148,6 +148,19 @@ internal void Complete(ReplyJob job, string[] ids) } } + internal void CompleteCommand(CommandJob job) + { + lock (_gate) + { + using var cmd = _db.CreateCommand(); + cmd.CommandText = "INSERT INTO queue_deliveries VALUES ($j,'sent',$c,'[]',$t) ON CONFLICT(job_id) DO NOTHING"; + cmd.Parameters.AddWithValue("$j", job.JobId); + cmd.Parameters.AddWithValue("$c", job.ConversationId); + cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + cmd.ExecuteNonQuery(); + } + } + internal void Prune(DateTimeOffset before) { lock (_gate) diff --git a/src/HuaJiBot.NET.Plugin.FlueBridge/Contracts.cs b/src/HuaJiBot.NET.Plugin.FlueBridge/Contracts.cs index c616273..2c2fcda 100644 --- a/src/HuaJiBot.NET.Plugin.FlueBridge/Contracts.cs +++ b/src/HuaJiBot.NET.Plugin.FlueBridge/Contracts.cs @@ -17,6 +17,10 @@ internal record ReplyDestination(string BridgeInstance, string RobotId, string G internal record ReplyContent(string Format, string Text); internal record ReplyJob(int Version, string JobId, string ConversationId, ReplyDestination Destination, ReplyContent Content, DateTimeOffset CreatedAt, string Kind = "chat"); +internal record CommandRequest(string Id, JsonElement Arguments); +internal record CommandJob(int Version, string JobId, string ConversationId, + ReplyDestination Destination, string SenderId, string SenderName, CommandRequest Command, + DateTimeOffset CreatedAt, string Kind = "command"); internal record QueueLease(string Body, string LeaseId); internal interface IReplyQueue diff --git a/src/HuaJiBot.NET.Plugin.FlueBridge/PluginMain.cs b/src/HuaJiBot.NET.Plugin.FlueBridge/PluginMain.cs index 3aaaa05..d482e6e 100644 --- a/src/HuaJiBot.NET.Plugin.FlueBridge/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.FlueBridge/PluginMain.cs @@ -1,5 +1,7 @@ -using System.Threading.Channels; +using System.Threading.Channels; using System.Collections.Concurrent; +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Commands; using HuaJiBot.NET.Events; using Newtonsoft.Json.Linq; @@ -41,7 +43,7 @@ protected override Task InitializeAsync() _queueHttp = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30) }; _ingress = new IngressClient(_http, new Uri(Config.IngressUrl), hmac, Config.MaxMessageBytes); _queue = new CloudflareQueue(_queueHttp, Config, token); - _engine = new BridgeEngine(Config, _store, Service, _queue, message => Warn(message)); + _engine = new BridgeEngine(Config, _store, Service, _queue, message => Warn(message), ExecuteCommandAsync); foreach (var destination in Config.Destinations) _engine.SeedSummaryAnchors(destination.RobotId, destination.GroupId); Service.Events.OnGroupMessageReceived += Receive; _running = Task.WhenAll(ForwardLoopAsync(_stop.Token), PullLoopAsync(_stop.Token)); @@ -144,6 +146,29 @@ private async Task PullLoopAsync(CancellationToken ct) catch (OperationCanceledException) when (ct.IsCancellationRequested) { } } + private async Task ExecuteCommandAsync(CommandJob job, CancellationToken ct) + { + if (Service is not BotService botService) + throw new InvalidOperationException("FlueBridge command execution requires a BotService."); + + var e = new GroupMessageEventArgs( + () => new DefaultCommandReader([]), + () => ValueTask.FromResult(string.Empty) + ) + { + Service = botService, + RobotId = job.Destination.RobotId, + MessageId = job.Destination.ReplyToMessageId, + GroupId = job.Destination.GroupId, + SenderId = job.SenderId, + SenderMemberCard = job.SenderName, + SenderMemberType = MemberType.Unknown, + TextMessageLazy = new(() => string.Empty), + }; + + await Service.CommandService.ExecuteCommandAsync(job.Command.Id, job.Command.Arguments, e, ct); + } + protected override void Unload() { Service.Events.OnGroupMessageReceived -= Receive; diff --git a/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs b/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs index 2a2125d..ec84ee6 100644 --- a/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs @@ -333,8 +333,8 @@ public string EnumToAttributeName(T value) [Command("事件", "开关事件", Alias = "events")] // ReSharper disable once UnusedMember.Global public void EventControlCommand( - [CommandArgumentEnum("类型")] ClientEventType? typeOptional, - [CommandArgumentString("状态")] string? status, + [CommandArgumentEnum("类型")] ClientEventType? type, + [CommandArgumentString("状态")] string? enabled, GroupMessageEventArgs e ) { @@ -343,7 +343,7 @@ GroupMessageEventArgs e e.Reply("只有群管理员可以修改事件转发设置"); return; } - if (typeOptional is not { } type) + if (type is not { } selectedType) { var allStatus = new StringBuilder(); foreach (var clientInfo in Config.Clients) @@ -373,14 +373,14 @@ select EnumToAttributeName(x) ); return; } - var name = EnumToAttributeName(type); - if (!StringToToggle(status ?? "", out var result)) + var name = EnumToAttributeName(selectedType); + if (!StringToToggle(enabled ?? "", out var result)) { var currentStatusDisabled = Config .Clients.SelectMany(x => x.Groups) .FirstOrDefault(x => x is { Enabled: true }) - ?.ForwardFromClientDisabledEvent.Contains(type) + ?.ForwardFromClientDisabledEvent.Contains(selectedType) ?? true; e.Reply("状态 可选:true、false\n" + $"当前{name}状态:{!currentStatusDisabled}"); return; @@ -393,9 +393,9 @@ select EnumToAttributeName(x) ) { if (result) - group.ForwardFromClientDisabledEvent.Remove(type); + group.ForwardFromClientDisabledEvent.Remove(selectedType); else - group.ForwardFromClientDisabledEvent.Add(type); + group.ForwardFromClientDisabledEvent.Add(selectedType); Service.Config.Save(); e.Reply($"已 {(result ? "开启" : "关闭")} 事件 {name} 的转发"); return; diff --git a/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs b/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs index d64c3d7..b3cb7df 100644 --- a/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs +++ b/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -195,7 +196,7 @@ SemanticModel semanticModel sb.AppendLine($" yield return new CommandInfo("); sb.AppendLine($" \"{key}\","); sb.AppendLine($" \"{description}\","); - sb.AppendLine($" args => "); + sb.AppendLine($" args =>"); sb.AppendLine($" {{"); // Check if method returns void @@ -238,23 +239,21 @@ SemanticModel semanticModel sb.AppendLine(");"); - if (!isVoid) + if (isVoid) + { + sb.AppendLine($" return Task.CompletedTask;"); + } + else if (method.ReturnType?.ToString().StartsWith("ValueTask", StringComparison.Ordinal) == true) + { + sb.AppendLine($" return result.AsTask();"); + } + else if (method.ReturnType?.ToString().StartsWith("Task", StringComparison.Ordinal) == true) + { + sb.AppendLine($" return result;"); + } + else { - sb.AppendLine($" if (result is Task task)"); - sb.AppendLine($" {{"); - sb.AppendLine($" task.ContinueWith("); - sb.AppendLine($" t =>"); - sb.AppendLine($" {{"); - sb.AppendLine($" if (t.Exception is not null)"); - sb.AppendLine($" {{"); - sb.AppendLine( - $" Error($\"执行命令 {key} 时出现异常:\", t.Exception);" - ); - sb.AppendLine($" }}"); - sb.AppendLine($" }},"); - sb.AppendLine($" TaskContinuationOptions.OnlyOnFaulted"); - sb.AppendLine($" );"); - sb.AppendLine($" }}"); + sb.AppendLine($" return Task.CompletedTask;"); } sb.AppendLine($" }},"); @@ -323,6 +322,7 @@ StringBuilder sb } sb.AppendLine($" new CommandArgumentInfo("); + sb.AppendLine($" \"{param.Identifier.ValueText}\","); sb.AppendLine($" {attributeCreation},"); sb.AppendLine($" typeof({typeForTypeof}),"); sb.AppendLine($" {isOptional},"); diff --git a/src/HuaJiBot.NET.UnitTest/FlueBridgeProtocolTest.cs b/src/HuaJiBot.NET.UnitTest/FlueBridgeProtocolTest.cs index 4f227c7..7bd53b4 100644 --- a/src/HuaJiBot.NET.UnitTest/FlueBridgeProtocolTest.cs +++ b/src/HuaJiBot.NET.UnitTest/FlueBridgeProtocolTest.cs @@ -17,12 +17,16 @@ public void SharedProtocolExamplesRoundTripThroughActualBridgeRecords() var ingress = JsonSerializer.Deserialize(ingressJson, BridgeJson.Options)!; var replyJson = ProtocolFixture("examples/reply-job.json"); var reply = JsonSerializer.Deserialize(replyJson, BridgeJson.Options)!; + var commandJson = ProtocolFixture("v2/examples/command-job.json"); + var command = JsonSerializer.Deserialize(commandJson, BridgeJson.Options)!; Assert.Multiple(() => { Assert.That(JsonNode.DeepEquals(JsonNode.Parse(ingressJson), JsonSerializer.SerializeToNode(ingress, BridgeJson.Options)), Is.True); Assert.That(JsonNode.DeepEquals(JsonNode.Parse(replyJson), JsonSerializer.SerializeToNode(reply, BridgeJson.Options)), Is.True); + Assert.That(JsonNode.DeepEquals(JsonNode.Parse(commandJson), JsonSerializer.SerializeToNode(command, BridgeJson.Options)), Is.True); Assert.That(ingress.Kind, Is.EqualTo("chat")); Assert.That(reply.Destination.ReplyToMessageId, Is.EqualTo(ingress.Message.MessageId)); + Assert.That(command.Command.Id, Is.EqualTo("schedule")); }); } diff --git a/src/HuaJiBot.NET.UnitTest/NaturalLanguageCommandTest.cs b/src/HuaJiBot.NET.UnitTest/NaturalLanguageCommandTest.cs new file mode 100644 index 0000000..08f5f64 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/NaturalLanguageCommandTest.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Commands; +using HuaJiBot.NET.Events; + +namespace HuaJiBot.NET.UnitTest; + +internal sealed class NaturalLanguageCommandPlugin : PluginBase +{ + public readonly List ScheduleCalls = []; + public readonly List<(TestEvent Type, string? Enabled)> EventCalls = []; + + [Command("日程", "查看近期日程", Alias = "schedule")] + public void Schedule([CommandArgumentString("时间")] string? weeks, GroupMessageEventArgs e) + { + ScheduleCalls.Add(weeks); + } + + [Command("事件", "开关事件", Alias = "events")] + public void Events( + [CommandArgumentEnum("类型")] TestEvent? type, + [CommandArgumentString("状态")] string? enabled, + GroupMessageEventArgs e + ) + { + if (type is { } selected) + EventCalls.Add((selected, enabled)); + } + + protected override void Unload() { } +} + +public enum TestEvent +{ + [CommandEnumItem("死亡", "玩家死亡")] + Death, +} + +internal sealed class NaturalLanguageCommandTest +{ + private readonly RecordingAdapter _adapter = new(); + + private GroupMessageEventArgs Message() + { + return new(() => new DefaultCommandReader([]), () => ValueTask.FromResult("group")) + { + Service = _adapter, + RobotId = "bot", + MessageId = "message-1", + GroupId = "group-1", + SenderId = "user-1", + SenderMemberCard = "User", + TextMessageLazy = new(() => string.Empty), + }; + } + + [Test] + public async Task StructuredCommandArgumentsUseParameterNamesAndEnumAliases() + { + var plugin = new NaturalLanguageCommandPlugin(); + var service = new CommandService(_adapter); + service.SetupCommands(plugin); + + await service.ExecuteCommandAsync( + "schedule", + JsonDocument.Parse("""{"weeks":2}""").RootElement.Clone(), + Message() + ); + await service.ExecuteCommandAsync( + "events", + JsonDocument.Parse("""{"type":"死亡","enabled":"false"}""").RootElement.Clone(), + Message() + ); + + Assert.That(plugin.ScheduleCalls, Is.EqualTo(new[] { "2" })); + Assert.That(plugin.EventCalls, Is.EqualTo(new[] { (TestEvent.Death, "false") })); + } + + [Test] + public void CommandCatalogExposesPoliciesAndExamples() + { + var plugin = new NaturalLanguageCommandPlugin(); + var service = new CommandService(_adapter); + service.SetupCommands(plugin); + + var catalog = service.GetCommandCatalog(); + + Assert.That(catalog.Select(x => x.Id), Does.Contain("schedule")); + Assert.That(catalog.Select(x => x.Id), Does.Contain("events")); + Assert.That(catalog.Single(x => x.Id == "events").AdminOnly, Is.True); + Assert.That(catalog.Single(x => x.Id == "schedule").Examples, Is.Not.Empty); + Assert.That(catalog.Single(x => x.Id == "events").Parameters.Select(x => x.Name), Is.EqualTo(new[] { "type", "enabled", "e" })); + } +} \ No newline at end of file diff --git a/src/HuaJiBot.NET.UnitTest/TestData/AgentProtocol/v2/command-job.schema.json b/src/HuaJiBot.NET.UnitTest/TestData/AgentProtocol/v2/command-job.schema.json new file mode 100644 index 0000000..6cbf18b --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/TestData/AgentProtocol/v2/command-job.schema.json @@ -0,0 +1,123 @@ +{ + "type": "object", + "properties": { + "version": { + "const": 2 + }, + "jobId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "conversationId": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "destination": { + "type": "object", + "properties": { + "bridgeInstance": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "robotId": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "groupId": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "replyToMessageId": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + }, + "required": [ + "bridgeInstance", + "robotId", + "groupId", + "replyToMessageId" + ], + "additionalProperties": false + }, + "kind": { + "const": "command" + }, + "senderId": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "senderName": { + "type": "string" + }, + "command": { + "type": "object", + "properties": { + "id": { + "enum": [ + "help", + "schedule", + "next", + "mc", + "events", + "tickets", + "summary" + ], + "type": "string" + }, + "arguments": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "default": {} + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "version", + "jobId", + "conversationId", + "destination", + "kind", + "senderId", + "senderName", + "command", + "createdAt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HuaJiBot local v2 command-job" +} diff --git a/src/HuaJiBot.NET.UnitTest/TestData/AgentProtocol/v2/examples/command-job.json b/src/HuaJiBot.NET.UnitTest/TestData/AgentProtocol/v2/examples/command-job.json new file mode 100644 index 0000000..8d3f89f --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/TestData/AgentProtocol/v2/examples/command-job.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "jobId": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "conversationId": "huajibot:development:test-bot:test-group:root", + "destination": { + "bridgeInstance": "development", + "robotId": "test-bot", + "groupId": "test-group", + "replyToMessageId": "root" + }, + "kind": "command", + "senderId": "user", + "senderName": "Alice", + "command": { + "id": "schedule", + "arguments": { + "weeks": 2 + } + }, + "createdAt": "2026-10-12T00:00:00+00:00" +} \ No newline at end of file diff --git a/src/HuaJiBot.NET/Bot/BotService.cs b/src/HuaJiBot.NET/Bot/BotService.cs index 18ab46f..fd6ff5d 100644 --- a/src/HuaJiBot.NET/Bot/BotService.cs +++ b/src/HuaJiBot.NET/Bot/BotService.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using HuaJiBot.NET.Agent; using HuaJiBot.NET.Events; using HuaJiBot.NET.Interfaces; @@ -203,6 +203,8 @@ protected BotService() public IReadOnlyDictionary> ExportFunctions => _commandService.ExportFunctions; + public ICommandService CommandService => _commandService; + public void SetupCommands(PluginBase plugin) => _commandService.SetupCommands(plugin); public string? ResolveCommandAlias(string alias) => _commandService.ResolveAlias(alias); diff --git a/src/HuaJiBot.NET/Bot/CommandService.cs b/src/HuaJiBot.NET/Bot/CommandService.cs index 18f0175..bb8ba35 100644 --- a/src/HuaJiBot.NET/Bot/CommandService.cs +++ b/src/HuaJiBot.NET/Bot/CommandService.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using HuaJiBot.NET.Agent; using HuaJiBot.NET.Commands; @@ -12,13 +13,12 @@ public class CommandService : ICommandService readonly Dictionary< string, - (string description, Action method, PluginBase.CommandArgumentInfo[] info) + (string description, Func method, PluginBase.CommandArgumentInfo[] info) > _commands = new(); - private readonly Dictionary _aliases = new(); + private readonly Dictionary _aliases = new(StringComparer.OrdinalIgnoreCase); - public string? ResolveAlias(string alias) => - _aliases.GetValueOrDefault(alias.ToLowerInvariant()); + public string? ResolveAlias(string alias) => _aliases.GetValueOrDefault(alias); public IEnumerable<(string Alias, string Description)> AliasedCommands => from x in _aliases @@ -37,146 +37,348 @@ public CommandService(BotService bot) public bool ProcessHelp(GroupMessageEventArgs e) { var reader = e.CommandReader; - if (reader.Match(["help", "帮助"], x => x, out _, true)) + if (!reader.Match(["help", "帮助"], x => x, out _, true)) + return false; + SendHelp(e); + return true; + } + + public void SendHelp(GroupMessageEventArgs e) + { + var sb = new StringBuilder(); + sb.AppendLine("可用命令:"); + foreach (var (name, (description, _, info)) in _commands) { - var sb = new StringBuilder(); - sb.AppendLine("可用命令:"); - foreach (var (name, (description, _, info)) in _commands) + sb.Append(name); + if (info.Length != 0) { - sb.Append(name); - if (info.Length != 0) + sb.Append(" "); + foreach (var arg in info) { - sb.Append(" "); - foreach (var arg in info) - { - if (arg.Attribute.ArgumentType == CommandArgumentType.Unknown) - { - continue; - } + if (arg.Attribute.ArgumentType == CommandArgumentType.Unknown) + continue; - string Quote(string str) + string Quote(string str) => arg.IsOptional ? $"[{str}]" : $"<{str}>"; + sb.Append( + arg.Attribute.ArgumentType switch { - if (arg.IsOptional) - return $"[{str}]"; - return $"<{str}>"; + CommandArgumentType.String => Quote("string"), + CommandArgumentType.RegexString => Quote("regex"), + CommandArgumentType.Enum => Quote( + string.Join( + "|", + from x in ((CommandArgumentEnumAttributeBase)arg.Attribute).EnumItems + select x.Key + ) + ), + CommandArgumentType.Unknown => "unknown", + _ => throw new ArgumentOutOfRangeException(nameof(arg.Attribute.ArgumentType)), } - sb.Append( - arg.Attribute.ArgumentType switch - { - CommandArgumentType.String => Quote("string"), - CommandArgumentType.RegexString => Quote("regex"), - CommandArgumentType.Enum => Quote( - string.Join( - "|", - from x in ( - (CommandArgumentEnumAttributeBase)arg.Attribute - ).EnumItems - select x.Key - ) - ), - CommandArgumentType.Unknown => "unknown", - _ => throw new ArgumentOutOfRangeException( - nameof(arg.Attribute.ArgumentType) - ), - } - ); - sb.Append(' '); - } + ); + sb.Append(' '); } - sb.AppendLine(); - sb.AppendLine($" {description}"); } - var lines = - from x in _commands - let alias = _aliases.FirstOrDefault(a => a.Value == x.Key).Key - let args = string.Concat( - from arg in x.Value.info - where arg.Attribute.ArgumentType != CommandArgumentType.Unknown - select arg.IsOptional - ? $" [{arg.Attribute.Description}]" - : $" <{arg.Attribute.Description}>" - ) - select $"• {(alias is null ? x.Key : "/" + alias)}{args} — {x.Value.description}"; - var post = new Post( - "**可用命令**\n" - + string.Join("\n", lines.Append("• /help — 查看全部命令").Select(Post.Escape)), - sb.ToString() - ); - _ = e.Service.SendGroupMessageAsync(e.RobotId, e.GroupId, new ReplyMessage(e.MessageId), post); - return true; + sb.AppendLine(); + sb.AppendLine($" {description}"); } - return false; + var lines = + from x in _commands + let alias = _aliases.FirstOrDefault(a => a.Value == x.Key).Key + let args = string.Concat( + from arg in x.Value.info + where arg.Attribute.ArgumentType != CommandArgumentType.Unknown + select arg.IsOptional + ? $" [{arg.Attribute.Description}]" + : $" <{arg.Attribute.Description}>" + ) + select $"• {(alias is null ? x.Key : "/" + alias)}{args} — {x.Value.description}"; + var post = new Post( + "**可用命令**\n" + + string.Join("\n", lines.Append("• /help — 查看全部命令").Select(Post.Escape)), + sb.ToString() + ); + _ = e.Service.SendGroupMessageAsync(e.RobotId, e.GroupId, new ReplyMessage(e.MessageId), post); } - public void ProcessCommand(GroupMessageEventArgs e) + public void ProcessCommand(GroupMessageEventArgs e) => _ = ProcessCommandSafeAsync(e); + + public async Task ProcessCommandAsync(GroupMessageEventArgs e) { if (ProcessHelp(e)) + return true; + + var reader = e.CommandReader; + if (!reader.Input(out var matched) || !_commands.TryGetValue(matched, out var matchedItem)) + return false; + + var (description, method, info) = matchedItem; + _bot.LogDebug($"{description} : {e.TextMessage}"); + var args = ParseArguments(reader, info, e); + await method(args); + return true; + } + + public async Task ExecuteCommandAsync( + string commandId, + JsonElement arguments, + GroupMessageEventArgs e, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.Equals(commandId, "help", StringComparison.OrdinalIgnoreCase)) { + SendHelp(e); return; } - var reader = e.CommandReader; - if (reader.Input(out var matched) && _commands.TryGetValue(matched, out var matchedItem)) + + var commandName = ResolveAlias(commandId); + if (commandName is null + && !_commands.ContainsKey(commandId)) + throw new ArgumentException($"未知命令:{commandId}", nameof(commandId)); + commandName ??= commandId; + + if (!_commands.TryGetValue(commandName, out var matchedItem)) + throw new ArgumentException($"当前未加载命令:{commandId}", nameof(commandId)); + + var args = BuildArguments(matchedItem.info, arguments, e); + await matchedItem.method(args); + } + + public IReadOnlyList GetCommandCatalog() + { + var result = new List + { + new( + "help", + "帮助", + "help", + "查看全部命令", + [], + true, + false, + false, + ["帮助", "有什么命令", "help"] + ) + }; + + foreach (var (name, (description, _, info)) in _commands) + { + var alias = _aliases.FirstOrDefault(x => x.Value == name).Key; + var id = alias ?? name; + var parameters = info + .Select(arg => new CommandParameterDescriptor( + arg.Name, + arg.Attribute.Description, + arg.Attribute.ArgumentType, + arg.Type, + arg.IsOptional, + arg.Type == typeof(GroupMessageEventArgs) + )) + .ToArray(); + var policy = PolicyFor(id); + result.Add(new CommandDescriptor( + id, + name, + alias, + description, + parameters, + policy.ReadOnly, + policy.AdminOnly, + policy.GroupScoped, + ExamplesFor(id) + )); + } + + return result; + } + + private async Task ProcessCommandSafeAsync(GroupMessageEventArgs e) + { + try + { + await ProcessCommandAsync(e); + } + catch (Exception exception) + { + _bot.LogError("执行命令时出现异常", exception); + } + } + + private static object?[]? ParseArguments( + CommandReader reader, + PluginBase.CommandArgumentInfo[] info, + GroupMessageEventArgs e + ) + { + if (info.Length == 0) + return null; + + var args = new object?[info.Length]; + for (var i = 0; i < info.Length; i++) { - var (description, method, info) = matchedItem; - _bot.LogDebug($"{description} : {e.TextMessage}"); - object?[]? args = null; - if (info.Any()) + var arg = info[i]; + object? value = null; + switch (arg.Attribute.ArgumentType) { - args = new object?[info.Length]; - for (var i = 0; i < info.Length; i++) - { - var arg = info[i]; - object? value = null; - switch (arg.Attribute.ArgumentType) + case CommandArgumentType.Unknown when arg.Type == typeof(GroupMessageEventArgs): + value = e; + break; + case CommandArgumentType.String: + if (!reader.Input(out var str)) + continue; + value = str; + break; + case CommandArgumentType.RegexString: { - case CommandArgumentType.Unknown - when arg.Type == typeof(GroupMessageEventArgs): - value = e; - break; - case CommandArgumentType.String: - { - if (!reader.Input(out var str)) - continue; - value = str; - } - break; - case CommandArgumentType.RegexString: - { - var regexAttr = (CommandArgumentStringMatchAttribute)arg.Attribute; - if (!reader.Input(out var str)) - continue; - if (!Regex.IsMatch(str, regexAttr.Pattern, regexAttr.Options)) - continue; - value = str; - } - break; - case CommandArgumentType.Enum: - { - var enumAttr = (CommandArgumentEnumAttributeBase)arg.Attribute; - if ( - !reader.Match( - enumAttr.EnumItems, - x => [x.Key, x.Alias], //枚举的key或者别名,匹配到同一个元素 - out var item - ) - ) - { - continue; - } - value = item.Value; - } - break; - default: - throw new ArgumentOutOfRangeException(); + var regexAttr = (CommandArgumentStringMatchAttribute)arg.Attribute; + if (!reader.Input(out var regexStr)) + continue; + if (!Regex.IsMatch(regexStr, regexAttr.Pattern, regexAttr.Options)) + continue; + value = regexStr; + break; } - args[i] = value; + case CommandArgumentType.Enum: + { + var enumAttr = (CommandArgumentEnumAttributeBase)arg.Attribute; + if (!reader.Match(enumAttr.EnumItems, x => [x.Key, x.Alias], out var item)) + continue; + value = item.Value; + break; + } + default: + throw new ArgumentOutOfRangeException(); + } + args[i] = value; + } + return args; + } + + private static object?[]? BuildArguments( + PluginBase.CommandArgumentInfo[] info, + JsonElement arguments, + GroupMessageEventArgs e + ) + { + if (info.Length == 0) + return null; + + var values = new object?[info.Length]; + for (var i = 0; i < info.Length; i++) + { + var arg = info[i]; + if (arg.Type == typeof(GroupMessageEventArgs)) + { + values[i] = e; + continue; + } + + JsonElement valueElement = default; + var hasValue = arguments.ValueKind == JsonValueKind.Object + && arguments.TryGetProperty(arg.Name, out valueElement); + if (!hasValue) + { + if (arg.IsOptional) + { + values[i] = arg.DefaultValue; + continue; } + throw new ArgumentException($"缺少参数:{arg.Name}", arg.Name); } - //匹配参数 - method(args); + + values[i] = ConvertArgument(valueElement, arg); + } + return values; + } + + private static object? ConvertArgument(JsonElement value, PluginBase.CommandArgumentInfo arg) + { + if (value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + if (arg.IsOptional) + return arg.DefaultValue; + throw new ArgumentException($"参数不能为空:{arg.Name}", arg.Name); } + + if (arg.Attribute is CommandArgumentEnumAttributeBase enumAttribute) + { + var text = value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); + text = NormalizeEnumValue(arg.Type, text); + var enumItem = enumAttribute.EnumItems.FirstOrDefault(x => + string.Equals(x.Key, text, StringComparison.OrdinalIgnoreCase) + || string.Equals(x.Alias, text, StringComparison.OrdinalIgnoreCase) + ); + if (enumItem is not null) + return enumItem.Value; + if (Enum.TryParse(arg.Type, text, true, out var enumValue)) + return enumValue; + throw new ArgumentException($"参数 {arg.Name} 的值无效:{text}", arg.Name); + } + + if (arg.Type == typeof(string)) + return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); + if (arg.Type == typeof(bool)) + return value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False + ? value.GetBoolean() + : bool.Parse(value.ToString()); + if (arg.Type == typeof(int)) + return value.GetInt32(); + if (arg.Type == typeof(long)) + return value.GetInt64(); + if (arg.Type == typeof(double)) + return value.GetDouble(); + if (arg.Type == typeof(decimal)) + return value.GetDecimal(); + if (arg.Type == typeof(DateTime)) + return value.GetDateTime(); + + throw new ArgumentException($"不支持的参数类型:{arg.Type.Name}", arg.Name); } + private static string NormalizeEnumValue(Type type, string? value) + { + var text = (value ?? "").Trim(); + if (type.Name == "SummaryTarget") + return text.ToLowerInvariant() switch + { + "today" or "今天" or "今日" => "今天", + "yesterday" or "昨天" or "昨日" => "昨天", + "day_before_yesterday" or "before_yesterday" or "前天" => "前天", + _ => text, + }; + if (type.Name == "ClientEventType") + return text.ToLowerInvariant() switch + { + "join" or "joinleft" or "加入" => "加入", + "chat" or "聊天" => "聊天", + "death" or "playerdeath" or "死亡" => "死亡", + "achievement" or "progress" or "playerachievement" or "进度" => "进度", + _ => text, + }; + return text; + } + + private static (bool ReadOnly, bool AdminOnly, bool GroupScoped) PolicyFor(string id) => + id.ToLowerInvariant() switch + { + "events" => (false, true, true), + "tickets" or "summary" => (true, false, true), + _ => (true, false, false), + }; + + private static string[] ExamplesFor(string id) => + id.ToLowerInvariant() switch + { + "schedule" => ["看看下周有什么安排", "最近两周的日程"], + "next" => ["下一次活动是什么", "最近有什么日程"], + "summary" => ["今天群里聊了什么", "总结一下昨天"], + "mc" => ["服务器现在有人吗", "看看 MC 在线情况"], + "events" => ["把死亡事件关掉", "打开聊天事件转发"], + "tickets" => ["还有维修工单吗", "看看进行中的维修单"], + _ => [], + }; + public void SetupCommands(PluginBase plugin) { foreach (var (name, description, method, info, alias) in plugin.GetAllCommands()) @@ -189,11 +391,7 @@ public void SetupCommands(PluginBase plugin) _bot.OnCommandsRegistered(AliasedCommands.Append(("help", "查看全部命令"))); if (_commands.Count != 0) { - //监听群消息事件,匹配命令 - void ProcessCommandInternal(object? sender, GroupMessageEventArgs e) - { - ProcessCommand(e); - } + void ProcessCommandInternal(object? sender, GroupMessageEventArgs e) => ProcessCommand(e); _bot.Events.OnGroupMessageReceived -= ProcessCommandInternal; _bot.Events.OnGroupMessageReceived += ProcessCommandInternal; _bot.Log($"从插件 {plugin.Name} 加载了 {_commands.Count} 条命令"); @@ -204,4 +402,4 @@ void ProcessCommandInternal(object? sender, GroupMessageEventArgs e) _exportFunctions[pluginName] = exportFunctions; } } -} +} \ No newline at end of file diff --git a/src/HuaJiBot.NET/Bot/ICommandService.cs b/src/HuaJiBot.NET/Bot/ICommandService.cs index 5982e53..ecfaf1d 100644 --- a/src/HuaJiBot.NET/Bot/ICommandService.cs +++ b/src/HuaJiBot.NET/Bot/ICommandService.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using HuaJiBot.NET.Agent; +using HuaJiBot.NET.Commands; using HuaJiBot.NET.Events; namespace HuaJiBot.NET.Bot; @@ -7,6 +9,10 @@ public interface ICommandService { bool ProcessHelp(GroupMessageEventArgs e); void ProcessCommand(GroupMessageEventArgs e); + Task ProcessCommandAsync(GroupMessageEventArgs e); + Task ExecuteCommandAsync(string commandId, JsonElement arguments, GroupMessageEventArgs e, CancellationToken cancellationToken = default); + void SendHelp(GroupMessageEventArgs e); + IReadOnlyList GetCommandCatalog(); void SetupCommands(PluginBase plugin); string? ResolveAlias(string alias); IReadOnlyDictionary> ExportFunctions { get; } diff --git a/src/HuaJiBot.NET/Commands/CommandDescriptor.cs b/src/HuaJiBot.NET/Commands/CommandDescriptor.cs new file mode 100644 index 0000000..7816298 --- /dev/null +++ b/src/HuaJiBot.NET/Commands/CommandDescriptor.cs @@ -0,0 +1,22 @@ +namespace HuaJiBot.NET.Commands; + +public sealed record CommandParameterDescriptor( + string Name, + string Description, + CommandArgumentType ArgumentType, + Type Type, + bool IsOptional, + bool IsRuntimeContext +); + +public sealed record CommandDescriptor( + string Id, + string Name, + string? Alias, + string Description, + IReadOnlyList Parameters, + bool ReadOnly, + bool AdminOnly, + bool GroupScoped, + IReadOnlyList Examples +); \ No newline at end of file diff --git a/src/HuaJiBot.NET/Interfaces/IPluginService.cs b/src/HuaJiBot.NET/Interfaces/IPluginService.cs index 52545f0..666221d 100644 --- a/src/HuaJiBot.NET/Interfaces/IPluginService.cs +++ b/src/HuaJiBot.NET/Interfaces/IPluginService.cs @@ -48,5 +48,6 @@ Func> fallback ConfigWrapper Config { get; } ILogger Logger { get; } IReadOnlyDictionary> ExportFunctions { get; } + ICommandService CommandService { get; } string GetPluginDataPath(); } diff --git a/src/HuaJiBot.NET/Plugin.cs b/src/HuaJiBot.NET/Plugin.cs index 1137b1e..774b8f2 100644 --- a/src/HuaJiBot.NET/Plugin.cs +++ b/src/HuaJiBot.NET/Plugin.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; using HuaJiBot.NET.Agent; using HuaJiBot.NET.Bot; @@ -70,12 +70,13 @@ protected internal virtual void Initialize() { } public record CommandInfo( string Name, string Description, - Action Method, + Func Method, CommandArgumentInfo[] Arguments, string? Alias = null ); public record CommandArgumentInfo( + string Name, CommandArgumentAttribute Attribute, Type Type, bool IsOptional, @@ -93,6 +94,7 @@ public virtual IEnumerable GetAllCommands() var arguments = ( from x in method.GetParameters() select new CommandArgumentInfo( + x.Name ?? "arg", x.GetCustomAttribute() ?? new CommandArgumentUnknownAttribute(x.ParameterType), x.ParameterType, @@ -106,19 +108,12 @@ from x in method.GetParameters() args => { var result = method.Invoke(this, args); - if (result is Task task) + return result switch { - task.ContinueWith( - t => - { - if (t.Exception is not null) - { - Error($"执行命令 {overload.Key} 时出现异常:", t.Exception); - } - }, - TaskContinuationOptions.OnlyOnFaulted - ); - } + Task task => task, + ValueTask valueTask => valueTask.AsTask(), + _ => Task.CompletedTask, + }; }, arguments, overload.Alias