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