feat(modules): port util + misc; expose Registry to handlers

Phase 5a of go-port-cloud-run plan: port first 2 of 4 modules (wordle/loldle
deferred to later phase). Port util.go, info.go, help.go, stickerid.go and
misc.go with tests. /help renders registry view; /info exposes chat/thread/
sender ids; /stickerid (private) returns bot-scoped file_ids; /ping writes
last_ping KV ms-epoch JSON for byte-parity, /mstats reads it, /fortytwo is
easter egg.

Registry-pointer-in-Deps required for /help to access module registry—pointer
captured at factory time, stable post-Build. Static factory catalog moved from
modules pkg to cmd/server to break import cycle. Code-review fixes applied in
same session: /info nil-deref guard, KV wire-format parity.
This commit is contained in:
tiennm99 committed 2026-05-09 08:24:52 +07:00
1 parent c7a631bffd
commit 45a57e1573
11 files changed
+597 -14

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
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 {
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
},
}
}
+130
View File
@@ -0,0 +1,130 @@
package util_test
import (
"context"
"strings"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/modules/util"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// helpTestNoop is a stand-in handler used only to satisfy the registry's
// non-nil-handler validator.
func helpTestNoop(_ context.Context, _ *bot.Bot, _ *models.Update) error { return nil }
// fakeFactory builds a module that exposes the supplied commands. Used to
// drive RenderHelp without touching the real util/misc factories (avoids a
// dependency back into the package under test).
func fakeFactory(name string, cmds []modules.Command) modules.Factory {
return func(_ modules.Deps) modules.Module {
return modules.Module{Name: name, Commands: cmds}
}
}
func TestRenderHelp_GroupsByModuleAndSkipsPrivate(t *testing.T) {
cmd := func(name string, vis modules.Visibility, desc string) modules.Command {
return modules.Command{Name: name, Visibility: vis, Description: desc, Handler: helpTestNoop}
}
factories := map[string]modules.Factory{
"alpha": fakeFactory("alpha", []modules.Command{
cmd("a_pub", modules.VisibilityPublic, "alpha public"),
cmd("a_prot", modules.VisibilityProtected, "alpha protected"),
cmd("a_priv", modules.VisibilityPrivate, "alpha private — must not appear"),
}),
"beta": fakeFactory("beta", []modules.Command{
cmd("b_pub", modules.VisibilityPublic, "beta <i>desc</i>"),
cmd("b_amp", modules.VisibilityPublic, `Tom & "Jerry"`),
}),
}
reg, err := modules.Build([]string{"alpha", "beta"}, factories, storage.NewMemoryProvider(), nil)
if err != nil {
t.Fatalf("Build: %v", err)
}
out := util.RenderHelp(reg)
for _, want := range []string{
"<b>alpha</b>",
"<b>beta</b>",
"/a_pub — alpha public",
"/a_prot — alpha protected (protected)",
// HTML in user descriptions must be escaped.
"beta &lt;i&gt;desc&lt;/i&gt;",
// Locks html.EscapeString contract: & → &amp;, " → &#34;.
"Tom &amp; &#34;Jerry&#34;",
// Support footer always present.
"github.com/tiennm99/miti99bot-go",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n---output---\n%s", want, out)
}
}
if strings.Contains(out, "a_priv") {
t.Errorf("output leaked private command\n---output---\n%s", out)
}
}
func TestRenderHelp_ModuleOrderMatchesEnvOrder(t *testing.T) {
cmd := func(name string) modules.Command {
return modules.Command{Name: name, Visibility: modules.VisibilityPublic, Description: name, Handler: helpTestNoop}
}
factories := map[string]modules.Factory{
"first": fakeFactory("first", []modules.Command{cmd("f1")}),
"second": fakeFactory("second", []modules.Command{cmd("s1")}),
}
// MODULES order: second,first → expect "second" section before "first".
reg, err := modules.Build([]string{"second", "first"}, factories, storage.NewMemoryProvider(), nil)
if err != nil {
t.Fatalf("Build: %v", err)
}
out := util.RenderHelp(reg)
iSecond := strings.Index(out, "<b>second</b>")
iFirst := strings.Index(out, "<b>first</b>")
if iSecond < 0 || iFirst < 0 {
t.Fatalf("missing sections; output:\n%s", out)
}
if iSecond >= iFirst {
t.Errorf("expected 'second' before 'first'; got second=%d first=%d\n%s", iSecond, iFirst, out)
}
}
func TestRenderHelp_OmitsModulesWithNoVisibleCommands(t *testing.T) {
cmd := func(name string, vis modules.Visibility) modules.Command {
return modules.Command{Name: name, Visibility: vis, Description: name, Handler: helpTestNoop}
}
factories := map[string]modules.Factory{
"shadow": fakeFactory("shadow", []modules.Command{
cmd("hidden", modules.VisibilityPrivate),
}),
"visible": fakeFactory("visible", []modules.Command{
cmd("seen", modules.VisibilityPublic),
}),
}
reg, err := modules.Build([]string{"shadow", "visible"}, factories, storage.NewMemoryProvider(), nil)
if err != nil {
t.Fatalf("Build: %v", err)
}
out := util.RenderHelp(reg)
if strings.Contains(out, "<b>shadow</b>") {
t.Errorf("module with only private commands should not render a section\n%s", out)
}
if !strings.Contains(out, "<b>visible</b>") {
t.Errorf("visible module section missing\n%s", out)
}
}
func TestRenderHelp_NilRegistryReturnsFooterOnly(t *testing.T) {
out := util.RenderHelp(nil)
if !strings.Contains(out, "no commands registered") {
t.Errorf("nil registry should render placeholder; got:\n%s", out)
}
if !strings.Contains(out, "github.com/tiennm99/miti99bot-go") {
t.Errorf("footer missing; got:\n%s", out)
}
}
+46
View File
@@ -0,0 +1,46 @@
package util
import (
"context"
"fmt"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot-go/internal/modules"
)
// infoCommand returns /info — replies plain text with chat / thread / sender
// IDs, with "n/a" fallbacks. Used to debug bot routing in groups + topics.
func infoCommand() modules.Command {
return modules.Command{
Name: "info",
Visibility: modules.VisibilityPublic,
Description: "Show chat id, thread id, and sender id (debug helper)",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
// Today the dispatcher only routes message-text commands, but
// guard so /info can be safely reused from other update paths.
return nil
}
chatID := fmt.Sprintf("%d", msg.Chat.ID)
// Telegram omits message_thread_id outside forum topics, so a 0
// here is "no thread", same as JS's `?? "n/a"`.
threadID := "n/a"
if msg.MessageThreadID != 0 {
threadID = fmt.Sprintf("%d", msg.MessageThreadID)
}
senderID := "n/a"
if msg.From != nil {
senderID = fmt.Sprintf("%d", msg.From.ID)
}
text := fmt.Sprintf("chat id: %s\nthread id: %s\nsender id: %s", chatID, threadID, senderID)
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: msg.Chat.ID,
Text: text,
})
return err
},
}
}
+75
View File
@@ -0,0 +1,75 @@
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 stickerIDUsage = "Reply to a sticker message with /stickerid to get its file_id.\n" +
"Usage: send a sticker to me, then tap Reply on it and type /stickerid."
// stickerIDCommand returns /stickerid — private dev helper. Reply to a
// sticker, run /stickerid, get the bot-scoped file_id back. Used to populate
// loldle's congrats/lose/giveup sticker pools.
func stickerIDCommand() modules.Command {
return modules.Command{
Name: "stickerid",
Visibility: modules.VisibilityPrivate,
Description: "Reply to a sticker with this command to get its bot-scoped file_id",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
sticker := stickerFrom(msg)
if sticker == nil {
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: msg.Chat.ID,
Text: stickerIDUsage,
})
return err
}
setName := sticker.SetName
if setName == "" {
setName = "(no set)"
}
emoji := sticker.Emoji
if emoji == "" {
emoji = "—"
}
var sb strings.Builder
sb.WriteString("<b>file_id</b>\n")
fmt.Fprintf(&sb, "<code>%s</code>\n\n", html.EscapeString(sticker.FileID))
sb.WriteString("<b>file_unique_id</b>\n")
fmt.Fprintf(&sb, "<code>%s</code>\n\n", html.EscapeString(sticker.FileUniqueID))
fmt.Fprintf(&sb, "set: %s · emoji: %s",
html.EscapeString(setName), html.EscapeString(emoji))
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: msg.Chat.ID,
Text: sb.String(),
ParseMode: models.ParseModeHTML,
})
return err
},
}
}
// stickerFrom pulls the sticker out of the *replied-to* message, mirroring the
// JS handler: ctx.message.reply_to_message.sticker.
func stickerFrom(msg *models.Message) *models.Sticker {
if msg.ReplyToMessage == nil {
return nil
}
return msg.ReplyToMessage.Sticker
}
+20
View File
@@ -0,0 +1,20 @@
// Package util implements /info, /help, /stickerid — the framework-validating
// "always on" module. /help is a pure renderer over the registry; the other
// two are debug helpers.
package util
import (
"github.com/tiennm99/miti99bot-go/internal/modules"
)
// New is the module Factory. Closes over Deps so each handler has access to
// the registry (for /help) and to the bot framework (for sending replies).
func New(deps modules.Deps) modules.Module {
return modules.Module{
Commands: []modules.Command{
infoCommand(),
helpCommand(deps.Registry),
stickerIDCommand(),
},
}
}