diff --git a/src/HuaJiBot.NET.Adapter.Telegram/CommandReader.cs b/src/HuaJiBot.NET.Adapter.Telegram/CommandReader.cs index 879ceb3..b13bc67 100644 --- a/src/HuaJiBot.NET.Adapter.Telegram/CommandReader.cs +++ b/src/HuaJiBot.NET.Adapter.Telegram/CommandReader.cs @@ -8,7 +8,7 @@ namespace HuaJiBot.NET.Adapter.Telegram; /// /// 指令读取 /// -internal class TelegramCommandReader(BotService service, Message msg) : CommonCommandReader +internal class TelegramCommandReader(TelegramAdapter service, Message msg) : CommonCommandReader { public override IEnumerable Msg { @@ -37,10 +37,16 @@ IEnumerable Parse() // Get the text content (from Text or Caption) var text = msg.Text ?? msg.Caption ?? string.Empty; + // "/日程" has no bot_command entity because Telegram commands are ASCII only. + var lastIndex = + text.StartsWith('/') + && msg.Entities?.FirstOrDefault() is not { Type: MessageEntityType.BotCommand, Offset: 0 } + ? 1 + : 0; + // Parse entities if present if (msg.Entities is { Length: > 0 } entities) { - var lastIndex = 0; foreach (var entity in entities) { @@ -59,9 +65,21 @@ IEnumerable Parse() { case MessageEntityType.Mention: // @username var mentionText = text.Substring(entity.Offset, entity.Length); - // For @username mentions, we don't have the user ID directly - // We'll yield it as text or try to extract from the message - yield return mentionText; + if (IsThisBot(mentionText[1..])) + yield return new ReaderAt(service.BotUser!.Id.ToString(), mentionText); + else + yield return mentionText; + break; + + case MessageEntityType.BotCommand: // /command or /command@bot + var command = text.Substring(entity.Offset + 1, entity.Length - 1); + if (command.IndexOf('@') is var at and >= 0) + { + if (!IsThisBot(command[(at + 1)..])) + yield break; + command = command[..at]; + } + yield return service.ResolveCommandAlias(command) ?? command; break; case MessageEntityType.TextMention: // mention with user object @@ -97,10 +115,10 @@ IEnumerable Parse() } } } - else if (!string.IsNullOrWhiteSpace(text)) + else if (!string.IsNullOrWhiteSpace(text[lastIndex..])) { // No entities, just yield the whole text - yield return text; + yield return text[lastIndex..]; } // Handle other message types that aren't text-based @@ -119,4 +137,7 @@ IEnumerable Parse() } } } + + private bool IsThisBot(string username) => + string.Equals(username, service.BotUser?.Username, StringComparison.OrdinalIgnoreCase); } diff --git a/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs b/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs index 7930eb8..66a449f 100644 --- a/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs +++ b/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs @@ -28,7 +28,7 @@ public class TelegramAdapter(string botToken, HttpClient? httpClient = null) : B public override required ILogger Logger { get; init; } - private User? _botUser; + internal User? BotUser { get; set; } protected override void ReconnectCore() { @@ -41,20 +41,20 @@ protected override async Task SetupServiceAsyncCore() { try { - _botUser = await _botClient.GetMe(_cancellationTokenSource.Token); + BotUser = await _botClient.GetMe(_cancellationTokenSource.Token); await _botClient.DeleteWebhook(); // you may comment this line if you find it unnecessary await _botClient.DropPendingUpdates(); // you may comment this line if you find it unnecessary Log( - $"Telegram bot started: @{_botUser.Username} ({_botUser.FirstName}) {_botUser.CanReadAllGroupMessages}" + $"Telegram bot started: @{BotUser.Username} ({BotUser.FirstName}) {BotUser.CanReadAllGroupMessages}" ); Events.CallOnBotLogin( new() { Service = this, - Accounts = [_botUser.Id.ToString()], + Accounts = [BotUser.Id.ToString()], ClientName = "Telegram Bot", - ClientVersion = _botUser.Username, + ClientVersion = BotUser.Username, } ); // Subscribe to events @@ -82,7 +82,7 @@ protected override async Task SetupServiceAsyncCore() } } - public override string[] AllRobots => _botUser != null ? [_botUser.Id.ToString()] : []; + public override string[] AllRobots => BotUser != null ? [BotUser.Id.ToString()] : []; internal static ReplyParameters? ToReplyParameters(string? messageId) => int.TryParse(messageId, out var id) ? new ReplyParameters { MessageId = id } : null; @@ -252,6 +252,24 @@ async Task SendAsync(ParseMode parseMode, Func compo } } + protected override void OnCommandsRegistered( + IEnumerable<(string Alias, string Description)> commands + ) + { + var menu = commands.Select(x => new BotCommand(x.Alias, x.Description)).ToArray(); + _ = Task.Run(async () => + { + try + { + await _botClient.SetMyCommands(menu, cancellationToken: _cancellationTokenSource.Token); + } + catch (Exception ex) + { + LogError("Failed to set the bot command menu", ex); + } + }); + } + // Telegram re-encodes photos as JPEG, which turns the card's transparent corners white. internal static async Task OpenPhotoAsync(string path) { @@ -450,7 +468,7 @@ private async Task HandleMessageAsync(Message message, UpdateType type) ) { Service = this, - RobotId = _botUser?.Id.ToString(), + RobotId = BotUser?.Id.ToString(), GroupId = null, // Private chats don't have group ID SenderId = userId, MessageId = message.MessageId.ToString(), @@ -491,7 +509,7 @@ await _botClient.GetChat( ) { Service = this, - RobotId = _botUser?.Id.ToString(), + RobotId = BotUser?.Id.ToString(), GroupId = groupTopic, SenderId = userId, SenderMemberCard = userName, diff --git a/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs b/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs index ec9860f..015ec20 100644 --- a/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.Calendar/PluginMain.cs @@ -144,7 +144,7 @@ private async Task EnterCooldownAsync(GroupMessageEventArgs e) return true; } - [Command("最近日程", "查看最近一次日程详细信息")] + [Command("最近日程", "查看最近一次日程详细信息", Alias = "next")] // ReSharper disable once UnusedMember.Global public async Task CalendarCommandAsync(GroupMessageEventArgs e) { @@ -167,7 +167,7 @@ public async Task CalendarCommandAsync(GroupMessageEventArgs e) await e.Reply(output); } - [Command("日程", "查看近期日程")] + [Command("日程", "查看近期日程", Alias = "schedule")] // ReSharper disable once UnusedMember.Global public async Task CalendarCommandAsync( [CommandArgumentString("时间")] string? content, diff --git a/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs b/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs index a09c88f..5222ade 100644 --- a/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.DailySummary/PluginMain.cs @@ -143,7 +143,7 @@ [new ChatMessage(ChatRole.User, userMessage)], : response.Text; } - [Command("总结", "总结群聊记录,可选今天、昨天、前天,默认昨天")] + [Command("总结", "总结群聊记录,可选今天、昨天、前天,默认昨天", Alias = "summary")] // ReSharper disable once UnusedMember.Global public async Task SummaryCommand( GroupMessageEventArgs e, diff --git a/src/HuaJiBot.NET.Plugin.MessageBridge/Commands.cs b/src/HuaJiBot.NET.Plugin.MessageBridge/Commands.cs index dfe7639..a5d0d99 100644 --- a/src/HuaJiBot.NET.Plugin.MessageBridge/Commands.cs +++ b/src/HuaJiBot.NET.Plugin.MessageBridge/Commands.cs @@ -17,7 +17,7 @@ ConcurrentBag data ) > _requestQueue = new(); - [Command("查询", "查询MC服务器状态")] + [Command("查询", "查询MC服务器状态", Alias = "mc")] // ReSharper disable once UnusedMember.Local private async Task QueryAsync(GroupMessageEventArgs e) { diff --git a/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs b/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs index e114974..2a2125d 100644 --- a/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs @@ -330,7 +330,7 @@ public string EnumToAttributeName(T value) return value.ToString(); } - [Command("事件", "开关事件")] + [Command("事件", "开关事件", Alias = "events")] // ReSharper disable once UnusedMember.Global public void EventControlCommand( [CommandArgumentEnum("类型")] ClientEventType? typeOptional, diff --git a/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs b/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs index af0bcfa..324768a 100644 --- a/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs +++ b/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs @@ -176,7 +176,7 @@ private void OnMessageReceived(object? sender, string msg) } } - [Command("工单", "查看进行中的维修工单")] + [Command("工单", "查看进行中的维修工单", Alias = "tickets")] // ReSharper disable once UnusedMember.Local private async Task TicketsCommandAsync(GroupMessageEventArgs e) { diff --git a/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs b/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs index 4b014e2..d64c3d7 100644 --- a/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs +++ b/src/HuaJiBot.NET.SourceGenerator/CommandSourceGenerator.cs @@ -185,6 +185,12 @@ SemanticModel semanticModel var key = GetStringLiteralValue(keyArg.Expression); var description = GetStringLiteralValue(descArg.Expression); + var aliasArg = commandAttr.ArgumentList.Arguments.FirstOrDefault(a => + a.NameEquals?.Name.Identifier.ValueText == "Alias" + ); + var alias = aliasArg is null + ? "null" + : $"\"{GetStringLiteralValue(aliasArg.Expression)}\""; sb.AppendLine($" yield return new CommandInfo("); sb.AppendLine($" \"{key}\","); @@ -261,7 +267,8 @@ SemanticModel semanticModel GenerateArgumentInfo(param, semanticModel, sb); } - sb.AppendLine($" }}"); + sb.AppendLine($" }},"); + sb.AppendLine($" {alias}"); sb.AppendLine($" );"); sb.AppendLine(); } diff --git a/src/HuaJiBot.NET.UnitTest/CommandMatchTest.cs b/src/HuaJiBot.NET.UnitTest/CommandMatchTest.cs index 2e58f2f..ccda6e8 100644 --- a/src/HuaJiBot.NET.UnitTest/CommandMatchTest.cs +++ b/src/HuaJiBot.NET.UnitTest/CommandMatchTest.cs @@ -18,10 +18,10 @@ protected override void Unload() { } private readonly RecordingAdapter _adapter = new(); - private GroupMessageEventArgs Message(string text) => + private GroupMessageEventArgs Message(string text, RecordingAdapter? adapter = null) => new(() => new DefaultCommandReader([text]), () => ValueTask.FromResult("group")) { - Service = _adapter, + Service = adapter ?? _adapter, MessageId = "1", GroupId = "group", SenderId = "user", @@ -65,6 +65,29 @@ public void Help_MatchesWholeWord(string text, bool expected) Assert.That(service.ProcessHelp(Message(text)), Is.EqualTo(expected)); } + [Test] + public async Task Help_OnQq_RepliesWithTheSameText() + { + RecordingAdapter adapter = new(); + var service = new CommandService(adapter); + service.SetupCommands(new SchedulePlugin()); + + service.ProcessHelp(Message("帮助", adapter)); + for (var i = 0; i < 50 && adapter.Sends.Count == 0; i++) + await Task.Delay(20); + + Assert.That( + adapter.Sends.Single().Messages, + Is.EqualTo( + new SendingMessageBase[] + { + new ReplyMessage("1"), + new TextMessage($"可用命令:{Environment.NewLine}日程 {Environment.NewLine} {Environment.NewLine}"), + } + ) + ); + } + [Test] public void RestOfMessage_WithReplyInTheMiddle_HasNoRecordText() { diff --git a/src/HuaJiBot.NET.UnitTest/Doubles.cs b/src/HuaJiBot.NET.UnitTest/Doubles.cs index 4ced28a..8c9105d 100644 --- a/src/HuaJiBot.NET.UnitTest/Doubles.cs +++ b/src/HuaJiBot.NET.UnitTest/Doubles.cs @@ -21,6 +21,13 @@ params SendingMessageBase[] messages Sends.Add((targetGroup, sent)); return ["42"]; } + + public override Task FeedbackAt( + string? robotId, + string targetGroup, + string msgId, + string text + ) => SendGroupMessageAsync(robotId, targetGroup, new ReplyMessage(msgId), new TextMessage(text)); } internal sealed class RichAdapter : TestAdapter diff --git a/src/HuaJiBot.NET.UnitTest/TelegramCommandTest.cs b/src/HuaJiBot.NET.UnitTest/TelegramCommandTest.cs new file mode 100644 index 0000000..3942983 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/TelegramCommandTest.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using HuaJiBot.NET.Adapter.Telegram; +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Commands; +using HuaJiBot.NET.Events; +using HuaJiBot.NET.Logger; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace HuaJiBot.NET.UnitTest; + +internal class TelegramCommandTest +{ + private class SchedulePlugin : PluginBase + { + [Command("日程", "查看近期日程", Alias = "schedule")] + public void Schedule([CommandArgumentString("周数")] string? weeks) { } + + protected override void Unload() { } + } + + private static TelegramAdapter Adapter(TelegramRichMessageFallbackTest.FakeBotApi api) + { + var adapter = new TelegramAdapter("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", new(api)) + { + Logger = new ConsoleLogger(), + BotUser = new User { Id = 42, IsBot = true, FirstName = "NBoT", Username = "nbtca_bot" }, + }; + adapter.SetupCommands(new SchedulePlugin()); + return adapter; + } + + private static Message Text(string text) + { + var first = text.Split(' ')[0]; + MessageEntity[]? entities = first switch + { + _ when first.StartsWith('/') && first[1..].All(char.IsAscii) => + [ + new() { Type = MessageEntityType.BotCommand, Offset = 0, Length = first.Length }, + ], + _ when first.StartsWith('@') => + [ + new() { Type = MessageEntityType.Mention, Offset = 0, Length = first.Length }, + ], + _ => null, + }; + return new() + { + Text = text, + Entities = entities, + Chat = new() { Id = -100, Type = ChatType.Supergroup }, + }; + } + + private static CommandReader Reader(string text) => + new TelegramCommandReader(Adapter(new()), Text(text)); + + [TestCase("/schedule 2")] + [TestCase("/schedule@nbtca_bot 2")] + [TestCase("/SCHEDULE@NBTCA_BOT 2")] + [TestCase("/日程 2")] + [TestCase("日程 2")] + public void Command_ResolvesToItsName(string text) + { + var reader = Reader(text); + + Assert.Multiple(() => + { + Assert.That(reader.Input(out var name), Is.True); + Assert.That(name, Is.EqualTo("日程")); + Assert.That(reader.Input(out var arg), Is.True); + Assert.That(arg, Is.EqualTo("2")); + }); + } + + [Test] + public void Command_ForAnotherBot_IsIgnored() => + Assert.That(Reader("/schedule@other_bot 2").Input(out _), Is.False); + + [Test] + public void Mention_OfThisBot_IsAnAt() + { + var reader = Reader("@nbtca_bot 你好"); + + Assert.Multiple(() => + { + Assert.That(reader.At(out var target), Is.True); + Assert.That(target, Is.EqualTo("42")); + }); + } + + [Test] + public async Task SetupCommands_RegistersAliasesInTheMenu() + { + TelegramRichMessageFallbackTest.FakeBotApi api = new(); + + Adapter(api); + for (var i = 0; i < 50 && !api.Methods.Contains("setMyCommands"); i++) + await Task.Delay(20); + + var commands = + from x in JsonDocument + .Parse(api.Bodies[api.Methods.LastIndexOf("setMyCommands")]) + .RootElement.GetProperty("commands") + .EnumerateArray() + select (x.GetProperty("command").GetString(), x.GetProperty("description").GetString()); + Assert.That(commands, Is.EqualTo(new[] { ("schedule", "查看近期日程"), ("help", "查看全部命令") })); + } + + [Test] + public async Task Help_OnTelegram_ListsAliases() + { + TelegramRichMessageFallbackTest.FakeBotApi api = new(); + var adapter = Adapter(api); + var service = new CommandService(adapter); + service.SetupCommands(new SchedulePlugin()); + + service.ProcessHelp( + new GroupMessageEventArgs(() => new DefaultCommandReader(["帮助"]), () => ValueTask.FromResult("group")) + { + Service = adapter, + MessageId = "1", + GroupId = "-100", + SenderId = "user", + SenderMemberCard = "user", + TextMessageLazy = new(() => "帮助"), + } + ); + for (var i = 0; i < 50 && !api.Methods.Contains("sendMessage"); i++) + await Task.Delay(20); + + var message = JsonDocument.Parse(api.Bodies[api.Methods.IndexOf("sendMessage")]).RootElement; + Assert.Multiple(() => + { + Assert.That( + message.GetProperty("text").GetString(), + Is.EqualTo("可用命令\n• /schedule <周数> — 查看近期日程\n• /help — 查看全部命令") + ); + Assert.That(message.GetProperty("reply_parameters").GetProperty("message_id").GetInt32(), Is.EqualTo(1)); + }); + } +} diff --git a/src/HuaJiBot.NET/Bot/BotService.cs b/src/HuaJiBot.NET/Bot/BotService.cs index 86aa43a..18ab46f 100644 --- a/src/HuaJiBot.NET/Bot/BotService.cs +++ b/src/HuaJiBot.NET/Bot/BotService.cs @@ -204,4 +204,11 @@ protected BotService() _commandService.ExportFunctions; public void SetupCommands(PluginBase plugin) => _commandService.SetupCommands(plugin); + + public string? ResolveCommandAlias(string alias) => _commandService.ResolveAlias(alias); + + protected internal virtual void OnCommandsRegistered( + IEnumerable<(string Alias, string Description)> commands + ) + { } } diff --git a/src/HuaJiBot.NET/Bot/CommandService.cs b/src/HuaJiBot.NET/Bot/CommandService.cs index 6ba047a..18f0175 100644 --- a/src/HuaJiBot.NET/Bot/CommandService.cs +++ b/src/HuaJiBot.NET/Bot/CommandService.cs @@ -15,6 +15,15 @@ readonly Dictionary< (string description, Action method, PluginBase.CommandArgumentInfo[] info) > _commands = new(); + private readonly Dictionary _aliases = new(); + + public string? ResolveAlias(string alias) => + _aliases.GetValueOrDefault(alias.ToLowerInvariant()); + + public IEnumerable<(string Alias, string Description)> AliasedCommands => + from x in _aliases + select (x.Key, _commands[x.Value].description); + private readonly Dictionary> _exportFunctions = new(); public IReadOnlyDictionary> ExportFunctions => @@ -77,7 +86,23 @@ select x.Key sb.AppendLine(); sb.AppendLine($" {description}"); } - e.Reply(sb.ToString()); + 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; } return false; @@ -154,11 +179,14 @@ out var item public void SetupCommands(PluginBase plugin) { - foreach (var (name, description, method, info) in plugin.GetAllCommands()) + foreach (var (name, description, method, info, alias) in plugin.GetAllCommands()) { _commands.Add(name, (description, method, info)); + if (alias is not null) + _aliases.Add(alias, name); _bot.Log($"读取命令 {name} ,描述:{description}"); } + _bot.OnCommandsRegistered(AliasedCommands.Append(("help", "查看全部命令"))); if (_commands.Count != 0) { //监听群消息事件,匹配命令 diff --git a/src/HuaJiBot.NET/Bot/ICommandService.cs b/src/HuaJiBot.NET/Bot/ICommandService.cs index e6de67d..5982e53 100644 --- a/src/HuaJiBot.NET/Bot/ICommandService.cs +++ b/src/HuaJiBot.NET/Bot/ICommandService.cs @@ -8,5 +8,6 @@ public interface ICommandService bool ProcessHelp(GroupMessageEventArgs e); void ProcessCommand(GroupMessageEventArgs e); void SetupCommands(PluginBase plugin); + string? ResolveAlias(string alias); IReadOnlyDictionary> ExportFunctions { get; } } diff --git a/src/HuaJiBot.NET/Commands/CommandAttribute.cs b/src/HuaJiBot.NET/Commands/CommandAttribute.cs index 27dc5ee..6e14287 100644 --- a/src/HuaJiBot.NET/Commands/CommandAttribute.cs +++ b/src/HuaJiBot.NET/Commands/CommandAttribute.cs @@ -9,6 +9,9 @@ public class CommandAttribute(string key, string description) : Attribute { public string Key => key; public string Description => description; + + /// Telegram-only /alias, shown in the bot command menu. + public string? Alias { get; set; } } public enum CommandArgumentType diff --git a/src/HuaJiBot.NET/Plugin.cs b/src/HuaJiBot.NET/Plugin.cs index 74e9daf..1137b1e 100644 --- a/src/HuaJiBot.NET/Plugin.cs +++ b/src/HuaJiBot.NET/Plugin.cs @@ -71,7 +71,8 @@ public record CommandInfo( string Name, string Description, Action Method, - CommandArgumentInfo[] Arguments + CommandArgumentInfo[] Arguments, + string? Alias = null ); public record CommandArgumentInfo( @@ -119,7 +120,8 @@ from x in method.GetParameters() ); } }, - arguments + arguments, + overload.Alias ); } }