mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(help): split /help into several messages past Telegram's length limit
This commit is contained in:
1 parent
7cc0ead54f
commit
4607ae5f14
4 files changed
+104
-18
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user