diff --git a/README.md b/README.md index 689990b..ddacafc 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ description: . Buy VN stock at market price. ``` -`/help` combines the full command syntax and summary on one line. Neither -discovery surface includes example invocations. +`/help` combines the full command syntax and summary on one line. When the +list exceeds Telegram's 4096-character message limit, `/help` sends it as +several messages, breaking only between modules. Neither discovery surface +includes example invocations. Future commands must follow the [command parameter conventions](docs/command-parameter-conventions.md). Keep diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 8c4f816..fd49304 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -138,9 +138,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { } } - help := moduleutil.RenderHelp(reg) - if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest { - t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest) + for i, help := range moduleutil.RenderHelpMessages(reg) { + if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest { + t.Fatalf("/help message %d source is %d characters, exceeds conservative Telegram limit %d", i+1, utf8.RuneCountInString(help), telegramMessageMaxRunesForTest) + } } } diff --git a/internal/modules/util/help.go b/internal/modules/util/help.go index 9870110..d262fb2 100644 --- a/internal/modules/util/help.go +++ b/internal/modules/util/help.go @@ -5,6 +5,7 @@ import ( "fmt" "html" "strings" + "unicode/utf8" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" @@ -19,17 +20,29 @@ var supportFooter = fmt.Sprintf( repoURL, repoURL, ) -// RenderHelp produces the body of /help: each module's public commands -// grouped under a bold module name, followed by the support footer. -// Modules in MODULES-env order. Modules with no visible commands are omitted. -// Protected and private commands are hidden; authorization-specific commands -// stay discoverable only through operator knowledge, not the public help/menu. +// helpMessageLimit is Telegram's message length cap. Telegram counts text +// after HTML tags are parsed out, so measuring the HTML source is conservative. +const helpMessageLimit = 4096 + +// RenderHelp produces the full body of /help: each module's public commands +// grouped under a bold module name, followed by the support footer. It is +// RenderHelpMessages joined back into one string. // // Exposed (capitalised) so tests can assert on the string without spinning up // a bot context. func RenderHelp(reg *modules.Registry) string { + return strings.Join(RenderHelpMessages(reg), "\n\n") +} + +// RenderHelpMessages renders /help as one or more messages, each within +// Telegram's length cap. Messages break only between module sections, and the +// support footer ends the last one. +// Modules in MODULES-env order. Modules with no visible commands are omitted. +// Protected and private commands are hidden; authorization-specific commands +// stay discoverable only through operator knowledge, not the public help/menu. +func RenderHelpMessages(reg *modules.Registry) []string { if reg == nil { - return "no commands registered\n\n" + supportFooter + return []string{"no commands registered\n\n" + supportFooter} } byModule := make(map[string][]modules.Command, len(reg.Modules)) @@ -53,12 +66,30 @@ func RenderHelp(reg *modules.Registry) string { } sections = append(sections, sb.String()) } - - body := "no commands registered" - if len(sections) > 0 { - body = strings.Join(sections, "\n\n") + if len(sections) == 0 { + sections = []string{"no commands registered"} } - return body + "\n\n" + supportFooter + return packHelpSections(append(sections, supportFooter), helpMessageLimit) +} + +// packHelpSections greedily joins sections with blank lines into messages of +// at most limit runes. A section longer than limit gets a message of its own. +func packHelpSections(sections []string, limit int) []string { + var messages []string + current := "" + for _, section := range sections { + candidate := section + if current != "" { + candidate = current + "\n\n" + section + } + if current != "" && utf8.RuneCountInString(candidate) > limit { + messages = append(messages, current) + current = section + continue + } + current = candidate + } + return append(messages, current) } // ownerOf finds the module that registered the named command. Linear scan @@ -85,15 +116,18 @@ func helpCommand(reg *modules.Registry) modules.Command { if update.Message == nil { return nil } - text := RenderHelp(reg) - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: update.Message.Chat.ID, - MessageThreadID: update.Message.MessageThreadID, - Text: text, - ParseMode: models.ParseModeHTML, - LinkPreviewOptions: &models.LinkPreviewOptions{IsDisabled: bot.True()}, - }) - return err + for _, text := range RenderHelpMessages(reg) { + if _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + MessageThreadID: update.Message.MessageThreadID, + Text: text, + ParseMode: models.ParseModeHTML, + LinkPreviewOptions: &models.LinkPreviewOptions{IsDisabled: bot.True()}, + }); err != nil { + return err + } + } + return nil }, } } diff --git a/internal/modules/util/help_test.go b/internal/modules/util/help_test.go index 5bcd99b..0d9baac 100644 --- a/internal/modules/util/help_test.go +++ b/internal/modules/util/help_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "unicode/utf8" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" @@ -161,3 +162,51 @@ func TestRenderHelp_NilRegistryReturnsFooterOnly(t *testing.T) { t.Errorf("footer missing; got:\n%s", out) } } + +func TestRenderHelpMessages_SplitsBetweenModulesUnderTelegramLimit(t *testing.T) { + factories := map[string]modules.Factory{} + var order []string + for i := range 12 { + name := "mod" + string(rune('a'+i)) + var cmds []modules.Command + for j := range 10 { + cmds = append(cmds, modules.Command{ + Name: name + "_" + string(rune('a'+j)), + Visibility: modules.VisibilityPublic, + Description: strings.Repeat("long description ", 2), + Handler: helpTestNoop, + }) + } + factories[name] = fakeFactory(name, cmds) + order = append(order, name) + } + reg, err := modules.Build(order, factories, storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + + messages := util.RenderHelpMessages(reg) + if len(messages) < 2 { + t.Fatalf("messages = %d, want the help split into several", len(messages)) + } + for i, m := range messages { + if n := utf8.RuneCountInString(m); n > 4096 { + t.Errorf("message %d is %d runes, over 4096", i+1, n) + } + if !strings.HasPrefix(m, "") { + t.Errorf("message %d does not start at a module section: %q", i+1, m[:min(len(m), 40)]) + } + if hasFooter := strings.Contains(m, "starring the repo"); hasFooter != (i == len(messages)-1) { + t.Errorf("message %d footer present = %v", i+1, hasFooter) + } + } + if got := strings.Join(messages, "\n\n"); got != util.RenderHelp(reg) { + t.Error("joined messages differ from RenderHelp") + } +} + +func TestRenderHelpMessages_SmallHelpIsOneMessage(t *testing.T) { + if got := util.RenderHelpMessages(nil); len(got) != 1 { + t.Fatalf("messages = %d, want 1", len(got)) + } +}