mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-20 02:18:23 +00:00
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:
1 parent
c7a631bffd
commit
45a57e1573
11 files changed
+597
-14
No files matched your search
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 <i>desc</i>",
|
||||
// Locks html.EscapeString contract: & → &, " → ".
|
||||
"Tom & "Jerry"",
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user