mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-21 04:24:48 +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:
+13
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: <ms-since-epoch number> }.
|
||||
// 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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