Files
miti99bot/internal/modules/util/help.go
T
tiennm99 9a3108a1c4 feat(server): high-priority hardening + critical blockers
Phase 1+2 of the 2026-05-09 review remediation plan:

- Go-version alignment (Dockerfile/go.mod) + 4 nil-deref guards + CI
  docker-build step (Phase 1, c89aa1c carried over).
- Env allowlist: secretEnvKeys denylist replaced; modules opt-in via
  RequiredEnv. Future API keys do not auto-leak.
- Visibility enforcement: dispatcher gates Private/Protected commands
  via BOT_OWNER_ID / ADMIN_USER_IDS; non-permitted callers are silently
  denied.
- Panic recovery in webhook handler; logs runtime/debug.Stack and
  returns 200 to prevent Telegram retry storm.
- Cron timeout reduced 5m -> 60s.
- MaxBytesError handled separately from generic decode errors so 413
  from MaxBytesReader is not shadowed by a 400.
- Emoji clue HTML-escaped defensively in loldle-emoji renderer.
- Tests added for dispatcher Auth.Permits + webhook panic recovery.
2026-05-09 15:52:15 +07:00

112 lines
3.0 KiB
Go

package util
import (
"context"
"fmt"
"html"
"strings"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot-go/internal/modules"
)
const repoURL = "https://github.com/tiennm99/miti99bot-go"
var supportFooter = fmt.Sprintf(
`Enjoying the bot? Support me by starring the repo: <a href="%s">%s</a>`,
repoURL, repoURL,
)
// RenderHelp produces the body of /help: each module's public + protected
// commands grouped under a bold module name, followed by the support footer.
// Modules in MODULES-env order. Modules with no visible commands are omitted.
// Private commands are always skipped.
//
// Exposed (capitalised) so tests can assert on the string without spinning up
// a bot context.
func RenderHelp(reg *modules.Registry) string {
if reg == nil {
return "no commands registered\n\n" + supportFooter
}
type entry struct {
name string
description string
protected bool
}
byModule := make(map[string][]entry, len(reg.Modules))
for _, c := range reg.PublicCommands() {
byModule[ownerOf(reg, c.Name)] = append(byModule[ownerOf(reg, c.Name)], entry{
name: c.Name, description: c.Description, protected: false,
})
}
for _, c := range reg.ProtectedCommands() {
byModule[ownerOf(reg, c.Name)] = append(byModule[ownerOf(reg, c.Name)], entry{
name: c.Name, description: c.Description, protected: true,
})
}
var sections []string
for _, mod := range reg.Modules {
es := byModule[mod.Name]
if len(es) == 0 {
continue
}
var sb strings.Builder
fmt.Fprintf(&sb, "<b>%s</b>", html.EscapeString(mod.Name))
for _, e := range es {
suffix := ""
if e.protected {
suffix = " (protected)"
}
fmt.Fprintf(&sb, "\n/%s — %s%s", e.name, html.EscapeString(e.description), suffix)
}
sections = append(sections, sb.String())
}
body := "no commands registered"
if len(sections) > 0 {
body = strings.Join(sections, "\n\n")
}
return body + "\n\n" + supportFooter
}
// ownerOf finds the module that registered the named command. Linear scan
// (modules are few; commands per module are few). Returns "" if not found —
// callers treat that as "skip".
func ownerOf(reg *modules.Registry, cmdName string) string {
for _, m := range reg.Modules {
for _, c := range m.Commands {
if c.Name == cmdName {
return m.Name
}
}
}
return ""
}
// helpCommand returns /help — pure renderer over the registry.
func helpCommand(reg *modules.Registry) modules.Command {
return modules.Command{
Name: "help",
Visibility: modules.VisibilityPublic,
Description: "Show all available commands",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
return nil
}
text := RenderHelp(reg)
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: text,
ParseMode: models.ParseModeHTML,
LinkPreviewOptions: &models.LinkPreviewOptions{IsDisabled: bot.True()},
})
return err
},
}
}