diff --git a/cmd/server/main.go b/cmd/server/main.go index 3a2b3d9..4e2a908 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,8 @@ import ( "time" "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/modules/misc" + "github.com/tiennm99/miti99bot-go/internal/modules/util" "github.com/tiennm99/miti99bot-go/internal/server" "github.com/tiennm99/miti99bot-go/internal/storage" "github.com/tiennm99/miti99bot-go/internal/telegram" @@ -25,6 +27,16 @@ var secretEnvKeys = []string{ "CRON_SHARED_SECRET", } +// factories is the static module catalog. Adding a new module is a one-line +// change here. Lives in main rather than the modules package to avoid an +// import cycle (modules → util → modules). +func factories() map[string]modules.Factory { + return map[string]modules.Factory{ + "util": util.New, + "misc": misc.New, + } +} + // firestoreInitTimeout caps client construction at startup. Cloud Run cold // start budget is 500ms target; firestore.NewClient is normally fast but // network blips can make it hang. Fail fast and let Cloud Run restart us. @@ -53,7 +65,7 @@ func main() { log.Fatalf("telegram bot init: %v", err) } - reg, err := modules.Build(cfg.Modules, modules.Factories, provider, cfg.ModuleEnv) + reg, err := modules.Build(cfg.Modules, factories(), provider, cfg.ModuleEnv) if err != nil { log.Fatalf("module registry: %v", err) } diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go new file mode 100644 index 0000000..900af7a --- /dev/null +++ b/internal/modules/misc/misc.go @@ -0,0 +1,100 @@ +// Package misc is a small stub module that proves the framework end-to-end: +// /ping (public, exercises KV write), /mstats (protected, exercises KV read), +// /fortytwo (private easter egg). +package misc + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// lastPingKey is the per-module KV key /ping writes and /mstats reads. +const lastPingKey = "last_ping" + +// lastPing mirrors the JS bot's wire format: { at: }. +// Stored as int64 ms-epoch (not time.Time → RFC3339) so a future cross-runtime +// KV export/import migration round-trips byte-for-byte. +type lastPing struct { + At int64 `json:"at"` +} + +// New is the module Factory. Captures the per-module Deps via closure so each +// command handler has direct access to its KV store. +func New(deps modules.Deps) modules.Module { + return modules.Module{ + Commands: []modules.Command{ + pingCommand(deps), + mstatsCommand(deps), + fortytwoCommand(), + }, + } +} + +func pingCommand(deps modules.Deps) modules.Command { + return modules.Command{ + Name: "ping", + Visibility: modules.VisibilityPublic, + Description: "Health check — replies pong and records last ping", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + // Best-effort write — if KV is unavailable, still reply. + payload := lastPing{At: time.Now().UTC().UnixMilli()} + if err := deps.KV.PutJSON(ctx, lastPingKey, payload); err != nil { + log.Printf("misc /ping: putJSON failed: %v", err) + } + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: "pong", + }) + return err + }, + } +} + +func mstatsCommand(deps modules.Deps) modules.Command { + return modules.Command{ + Name: "mstats", + Visibility: modules.VisibilityProtected, + Description: "Show the timestamp of the last /ping", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + var last lastPing + text := "last ping: never" + err := deps.KV.GetJSON(ctx, lastPingKey, &last) + switch { + case err == nil && last.At > 0: + text = fmt.Sprintf("last ping: %s", + time.UnixMilli(last.At).UTC().Format(time.RFC3339)) + case err != nil && !errors.Is(err, storage.ErrNotFound): + return fmt.Errorf("misc /mstats: %w", err) + } + _, err = b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: text, + }) + return err + }, + } +} + +func fortytwoCommand() modules.Command { + return modules.Command{ + Name: "fortytwo", + Visibility: modules.VisibilityPrivate, + Description: "Easter egg — the answer", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: "The answer.", + }) + return err + }, + } +} diff --git a/internal/modules/misc/misc_test.go b/internal/modules/misc/misc_test.go new file mode 100644 index 0000000..0802553 --- /dev/null +++ b/internal/modules/misc/misc_test.go @@ -0,0 +1,83 @@ +package misc + +import ( + "context" + "testing" + "time" + + "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// We test the per-command KV behaviour directly — the bot/Telegram side is +// thin (single SendMessage) and exercising it would require a fake bot HTTP +// server. The KV interaction is the part with logic worth locking down. + +func TestNew_RegistersExpectedCommands(t *testing.T) { + deps := modules.Deps{KV: storage.NewMemoryKVStore()} + mod := New(deps) + + want := map[string]modules.Visibility{ + "ping": modules.VisibilityPublic, + "mstats": modules.VisibilityProtected, + "fortytwo": modules.VisibilityPrivate, + } + if len(mod.Commands) != len(want) { + t.Fatalf("commands count = %d, want %d", len(mod.Commands), len(want)) + } + for _, c := range mod.Commands { + v, ok := want[c.Name] + if !ok { + t.Errorf("unexpected command %q", c.Name) + continue + } + if c.Visibility != v { + t.Errorf("command %q visibility = %d, want %d", c.Name, c.Visibility, v) + } + if c.Handler == nil { + t.Errorf("command %q has nil handler", c.Name) + } + } +} + +func TestPing_WritesLastPingKV(t *testing.T) { + ctx := context.Background() + kv := storage.NewMemoryKVStore() + + // Drive the KV side directly: lock the wire format (ms-epoch number, not + // RFC3339 string). A JS-written {at: 1700000000000} must round-trip into + // the Go struct without a custom decoder. + if err := kv.PutJSON(ctx, lastPingKey, lastPing{At: time.Now().UTC().UnixMilli()}); err != nil { + t.Fatalf("PutJSON: %v", err) + } + + var got lastPing + if err := kv.GetJSON(ctx, lastPingKey, &got); err != nil { + t.Fatalf("GetJSON: %v", err) + } + if got.At <= 0 { + t.Errorf("read-back lastPing.At = %d, want positive ms-epoch", got.At) + } + + // Also verify a value with the JS-shape decodes correctly. + if err := kv.Put(ctx, lastPingKey, []byte(`{"at":1700000000000}`)); err != nil { + t.Fatal(err) + } + got = lastPing{} + if err := kv.GetJSON(ctx, lastPingKey, &got); err != nil { + t.Fatalf("GetJSON js-shape: %v", err) + } + if got.At != 1700000000000 { + t.Errorf("js-shape round-trip: At = %d, want 1700000000000", got.At) + } +} + +func TestMstats_MissingKVReturnsErrNotFound(t *testing.T) { + ctx := context.Background() + kv := storage.NewMemoryKVStore() + + var dst lastPing + if err := kv.GetJSON(ctx, lastPingKey, &dst); err != storage.ErrNotFound { + t.Errorf("GetJSON missing = %v, want ErrNotFound", err) + } +} diff --git a/internal/modules/module.go b/internal/modules/module.go index 39300a3..c48db61 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -59,14 +59,20 @@ type Module struct { } // Deps is the dependency bundle a Factory receives. Each field is added in the -// phase that introduces it; today only KV + Env exist (Firestore: Phase 04, -// Gemini: Phase 07). +// phase that introduces it; today KV, Env, and Registry exist (Gemini: Phase 07). // // Deps.Env is the process environment with sensitive keys stripped. Modules // must not assume Env contains every variable — see cmd/server.envForModules. +// +// Deps.Registry is a pointer to the Registry being built. At factory call +// time the Registry is partially populated (only modules earlier in the +// MODULES env order); by the time any handler runs, it is fully populated. +// Modules that need to introspect commands (e.g. /help) capture this pointer +// in their handler closures. type Deps struct { - KV storage.KVStore // already prefixed with the module name when passed to a Factory - Env map[string]string // process env minus known-sensitive keys + KV storage.KVStore // already prefixed with the module name when passed to a Factory + Env map[string]string // process env minus known-sensitive keys + Registry *Registry // populated by Build; safe to capture but read-only at module use } // Factory constructs a Module from its Deps. Spec deviation: Phase 03 plan diff --git a/internal/modules/modules.go b/internal/modules/modules.go index 8101b9e..7ee4e9c 100644 --- a/internal/modules/modules.go +++ b/internal/modules/modules.go @@ -1,10 +1,8 @@ package modules -// Factories is the static module catalog. Each phase that introduces a module -// adds an entry here. Phase 03 ships an empty catalog; Phase 05 onwards -// populates it. +// This file used to hold a static `Factories` catalog. With concrete modules +// now living in subpackages (internal/modules/util, /misc, …), keeping the +// catalog here would create an import cycle (modules → util → modules). // -// Spec deviation: Phase 03 plan defined a `[]Factory` slice. A map keyed by -// module name is required for Build() to honor the MODULES env CSV without a -// linear scan, and prevents duplicate module names at compile-load time. -var Factories = map[string]Factory{} +// The composition root in cmd/server owns the catalog instead. Tests pass +// their own catalog into Build, exercising only the modules they care about. diff --git a/internal/modules/registry.go b/internal/modules/registry.go index e575936..a9679ab 100644 --- a/internal/modules/registry.go +++ b/internal/modules/registry.go @@ -14,6 +14,10 @@ var moduleNameRe = commandNameRe // alias kept for symmetry; one regex serves bo // Registry holds the resolved set of modules selected by the MODULES env var. // It is built once at startup; Build fails fast on validation or conflict. +// +// Read-only after Build returns. Callers must not mutate any field — +// dispatchers and handlers capture *Registry by pointer and assume the maps +// are stable. A future hot-reload feature would need an explicit mutation API. type Registry struct { Modules []Module // in MODULES-env order AllCommands map[string]Command // name → Command, deduped across modules @@ -101,8 +105,9 @@ func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider } moduleDeps := Deps{ - KV: kv.For(name), - Env: env, + KV: kv.For(name), + Env: env, + Registry: reg, } mod := factory(moduleDeps) mod.Name = name // enforce: module name is its registry key, not whatever the factory chose diff --git a/internal/modules/util/help.go b/internal/modules/util/help.go new file mode 100644 index 0000000..9b9ba3f --- /dev/null +++ b/internal/modules/util/help.go @@ -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: %s`, + 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, "%s", 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 + }, + } +} diff --git a/internal/modules/util/help_test.go b/internal/modules/util/help_test.go new file mode 100644 index 0000000..49db269 --- /dev/null +++ b/internal/modules/util/help_test.go @@ -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 desc"), + 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{ + "alpha", + "beta", + "/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, "second") + iFirst := strings.Index(out, "first") + 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, "shadow") { + t.Errorf("module with only private commands should not render a section\n%s", out) + } + if !strings.Contains(out, "visible") { + 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) + } +} diff --git a/internal/modules/util/info.go b/internal/modules/util/info.go new file mode 100644 index 0000000..cdb94ae --- /dev/null +++ b/internal/modules/util/info.go @@ -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 + }, + } +} diff --git a/internal/modules/util/stickerid.go b/internal/modules/util/stickerid.go new file mode 100644 index 0000000..b8435bf --- /dev/null +++ b/internal/modules/util/stickerid.go @@ -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("file_id\n") + fmt.Fprintf(&sb, "%s\n\n", html.EscapeString(sticker.FileID)) + sb.WriteString("file_unique_id\n") + fmt.Fprintf(&sb, "%s\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 +} diff --git a/internal/modules/util/util.go b/internal/modules/util/util.go new file mode 100644 index 0000000..3f13277 --- /dev/null +++ b/internal/modules/util/util.go @@ -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(), + }, + } +}