diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go index d3152ec3d..281203cd3 100644 --- a/pkg/channels/telegram/command_registration.go +++ b/pkg/channels/telegram/command_registration.go @@ -31,16 +31,7 @@ func commandRegistrationDelay(attempt int) time.Duration { // RegisterCommands registers bot commands on Telegram platform. func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error { - botCommands := make([]telego.BotCommand, 0, len(defs)) - for _, def := range defs { - if def.Name == "" || def.Description == "" { - continue - } - botCommands = append(botCommands, telego.BotCommand{ - Command: def.Name, - Description: def.Description, - }) - } + botCommands := buildTelegramBotCommands(defs) current, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{}) if err != nil { @@ -57,6 +48,34 @@ func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands. }) } +func buildTelegramBotCommands(defs []commands.Definition) []telego.BotCommand { + const modelsCommand = "models" + const modelsDescription = "List models or switch: /models " + + botCommands := make([]telego.BotCommand, 0, len(defs)+1) + hasModelsShortcut := false + for _, def := range defs { + if def.Name == "" || def.Description == "" { + continue + } + if def.Name == modelsCommand { + hasModelsShortcut = true + } + botCommands = append(botCommands, telego.BotCommand{ + Command: def.Name, + Description: def.Description, + }) + } + if !hasModelsShortcut { + botCommands = append(botCommands, telego.BotCommand{ + Command: modelsCommand, + Description: modelsDescription, + }) + } + + return botCommands +} + func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) { if len(defs) == 0 { return diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go index 26f891b2e..781a05da9 100644 --- a/pkg/channels/telegram/command_registration_test.go +++ b/pkg/channels/telegram/command_registration_test.go @@ -3,10 +3,13 @@ package telegram import ( "context" "errors" + "slices" "sync/atomic" "testing" "time" + "github.com/mymmrac/telego" + "github.com/sipeed/picoclaw/pkg/commands" ) @@ -94,3 +97,37 @@ func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { t.Fatalf("expected retries to quiesce after cancel, got %d -> %d", stable, attempts.Load()) } } + +func TestBuildTelegramBotCommands_AddsModelsShortcutWhenMissing(t *testing.T) { + defs := []commands.Definition{ + {Name: "help", Description: "Help"}, + {Name: "list", Description: "List"}, + } + + got := buildTelegramBotCommands(defs) + + models := telego.BotCommand{ + Command: "models", + Description: "List models or switch: /models ", + } + if !slices.Contains(got, models) { + t.Fatalf("expected models shortcut in bot commands, got: %#v", got) + } +} + +func TestBuildTelegramBotCommands_DoesNotDuplicateExistingModelsCommand(t *testing.T) { + defs := []commands.Definition{ + {Name: "models", Description: "Configured models"}, + } + + got := buildTelegramBotCommands(defs) + count := 0 + for _, cmd := range got { + if cmd.Command == "models" { + count++ + } + } + if count != 1 { + t.Fatalf("expected 1 models command, got %d (%#v)", count, got) + } +} diff --git a/pkg/channels/telegram/model_shortcut.go b/pkg/channels/telegram/model_shortcut.go new file mode 100644 index 000000000..86177dd11 --- /dev/null +++ b/pkg/channels/telegram/model_shortcut.go @@ -0,0 +1,31 @@ +package telegram + +import "strings" + +// rewriteModelShortcut normalizes Telegram-specific /models shortcuts into +// existing cross-channel commands handled by the command runtime. +func rewriteModelShortcut(input, botUsername string) string { + parts := strings.Fields(strings.TrimSpace(input)) + if len(parts) == 0 { + return input + } + + token := parts[0] + if !strings.HasPrefix(token, "/") { + return input + } + + name, target, hasTarget := strings.Cut(strings.TrimPrefix(token, "/"), "@") + if !strings.EqualFold(name, "models") { + return input + } + if hasTarget && (botUsername == "" || !strings.EqualFold(target, botUsername)) { + return input + } + + if len(parts) == 1 { + return "/list models" + } + + return "/switch model to " + strings.Join(parts[1:], " ") +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f64a8f79b..ae4670eca 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -583,7 +583,11 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } if message.Text != "" { - content += message.Text + botUsername := "" + if c.bot != nil { + botUsername = c.bot.Username() + } + content += rewriteModelShortcut(message.Text, botUsername) } if message.Caption != "" { diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go index 0eb1de5ea..964f93842 100644 --- a/pkg/channels/telegram/telegram_dispatch_test.go +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -46,3 +46,85 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { t.Fatalf("content=%q", inbound.Content) } } + +func TestHandleMessage_RewritesModelsShortcutToListModels(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "/models", + MessageID: 10, + Chat: telego.Chat{ + ID: 123, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + } + + if err := ch.handleMessage(context.Background(), msg); err != nil { + t.Fatalf("handleMessage error: %v", err) + } + + inbound, ok := <-messageBus.InboundChan() + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != "/list models" { + t.Fatalf("content=%q", inbound.Content) + } +} + +func TestHandleMessage_RewritesModelsShortcutWithArgumentToSwitchModel(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "/models qwen-max", + MessageID: 11, + Chat: telego.Chat{ + ID: 123, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + } + + if err := ch.handleMessage(context.Background(), msg); err != nil { + t.Fatalf("handleMessage error: %v", err) + } + + inbound, ok := <-messageBus.InboundChan() + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != "/switch model to qwen-max" { + t.Fatalf("content=%q", inbound.Content) + } +} + +func TestRewriteModelShortcut_WithBotUsernameTarget(t *testing.T) { + got := rewriteModelShortcut("/models@testbot qwen-max", "testbot") + if got != "/switch model to qwen-max" { + t.Fatalf("rewrite=%q, want %q", got, "/switch model to qwen-max") + } +} + +func TestRewriteModelShortcut_DoesNotRewriteOtherBotTarget(t *testing.T) { + got := rewriteModelShortcut("/models@otherbot qwen-max", "testbot") + if got != "/models@otherbot qwen-max" { + t.Fatalf("rewrite=%q, want unchanged", got) + } +}