feat(help): split /help into several messages past Telegram's length limit

This commit is contained in:
tiennm99 committed 2026-10-01 10:28:09 +07:00
1 parent 7cc0ead54f
commit 4607ae5f14
4 files changed
+104 -18

No files matched your search

+4 -2
View File
@@ -40,8 +40,10 @@ description:
<quantity> <ticker>. Buy VN stock at market price. <quantity> <ticker>. Buy VN stock at market price.
``` ```
`/help` combines the full command syntax and summary on one line. Neither `/help` combines the full command syntax and summary on one line. When the
discovery surface includes example invocations. 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 Future commands must follow the
[command parameter conventions](docs/command-parameter-conventions.md). Keep [command parameter conventions](docs/command-parameter-conventions.md). Keep
+3 -2
View File
@@ -138,9 +138,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
} }
} }
help := moduleutil.RenderHelp(reg) for i, help := range moduleutil.RenderHelpMessages(reg) {
if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest { if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest {
t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest) t.Fatalf("/help message %d source is %d characters, exceeds conservative Telegram limit %d", i+1, utf8.RuneCountInString(help), telegramMessageMaxRunesForTest)
}
} }
} }
+48 -14
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"html" "html"
"strings" "strings"
"unicode/utf8"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
@@ -19,17 +20,29 @@ var supportFooter = fmt.Sprintf(
repoURL, repoURL, repoURL, repoURL,
) )
// RenderHelp produces the body of /help: each module's public commands // helpMessageLimit is Telegram's message length cap. Telegram counts text
// grouped under a bold module name, followed by the support footer. // after HTML tags are parsed out, so measuring the HTML source is conservative.
// Modules in MODULES-env order. Modules with no visible commands are omitted. const helpMessageLimit = 4096
// Protected and private commands are hidden; authorization-specific commands
// stay discoverable only through operator knowledge, not the public help/menu. // 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 // Exposed (capitalised) so tests can assert on the string without spinning up
// a bot context. // a bot context.
func RenderHelp(reg *modules.Registry) string { 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 { 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)) byModule := make(map[string][]modules.Command, len(reg.Modules))
@@ -53,12 +66,30 @@ func RenderHelp(reg *modules.Registry) string {
} }
sections = append(sections, sb.String()) sections = append(sections, sb.String())
} }
if len(sections) == 0 {
body := "no commands registered" sections = []string{"no commands registered"}
if len(sections) > 0 {
body = strings.Join(sections, "\n\n")
} }
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 // 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 { if update.Message == nil {
return nil return nil
} }
text := RenderHelp(reg) for _, text := range RenderHelpMessages(reg) {
_, err := b.SendMessage(ctx, &bot.SendMessageParams{ if _, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: update.Message.Chat.ID, ChatID: update.Message.Chat.ID,
MessageThreadID: update.Message.MessageThreadID, MessageThreadID: update.Message.MessageThreadID,
Text: text, Text: text,
ParseMode: models.ParseModeHTML, ParseMode: models.ParseModeHTML,
LinkPreviewOptions: &models.LinkPreviewOptions{IsDisabled: bot.True()}, LinkPreviewOptions: &models.LinkPreviewOptions{IsDisabled: bot.True()},
}) }); err != nil {
return err return err
}
}
return nil
}, },
} }
} }
+49
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"strings" "strings"
"testing" "testing"
"unicode/utf8"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
@@ -161,3 +162,51 @@ func TestRenderHelp_NilRegistryReturnsFooterOnly(t *testing.T) {
t.Errorf("footer missing; got:\n%s", out) 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, "<b>") {
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))
}
}