From 11f458fee52613d86f858fa4194e7658eac5a24a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 15:17:11 +0700 Subject: [PATCH] feat(random): group the random pickers into a random module and add /gachabeta /random, /wheelofnames, and /gacha move from misc into a new random module with unchanged command names, so usage stats carry over. The new unlisted /gachabeta takes the same input as /gacha and renders the toon-shaded beta wish from /api/gachabeta on the same service, with the same text fallback. --- .env.example | 2 +- README.md | 6 +- cmd/server/main.go | 2 + docs/deploy-coolify-selfhosted.md | 5 + internal/modules/misc/handlers_test.go | 278 ---------------- internal/modules/misc/misc.go | 7 +- internal/modules/misc/misc_test.go | 3 - internal/modules/random/gacha_api_client.go | 45 ++- .../modules/random/gacha_api_client_test.go | 10 +- internal/modules/random/gacha_command.go | 35 +- internal/modules/random/gacha_command_test.go | 62 +++- internal/modules/random/handlers_test.go | 306 ++++++++++++++++++ internal/modules/random/random.go | 20 ++ internal/modules/random/random_picker.go | 2 +- internal/modules/random/random_test.go | 35 ++ .../modules/random/wheelofnames_api_client.go | 2 +- .../random/wheelofnames_api_client_test.go | 2 +- .../modules/random/wheelofnames_command.go | 2 +- 18 files changed, 493 insertions(+), 331 deletions(-) create mode 100644 internal/modules/random/handlers_test.go create mode 100644 internal/modules/random/random.go create mode 100644 internal/modules/random/random_test.go diff --git a/.env.example b/.env.example index bb2e67d..d80c0f3 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ MONGO_DATABASE=miti99bot # Comma-separated module list. Empty = load every module, including any module # added later — so list them explicitly when a deployment should only gain a new # module deliberately. -MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker,alias,blacklist +MODULES=util,misc,random,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker,alias,blacklist # Telegram user id for owner-only commands (renamed from BOT_OWNER_ID). OWNER_ID= # Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS). diff --git a/README.md b/README.md index 45c5f59..8c47901 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Atlas via long polling and an in-process cron scheduler. | Module | What it does | |---|---| | `util` | `/help`, `/info` (admin), `/stickerid` (owner) | -| `misc` | `/ping`, `/ping_stats` (admin), `/random`, `/wheelofnames`, `/gacha` (Genshin-style wish; options are 5★ by default, prefix `4*` or `3*`), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | +| `misc` | `/ping`, `/ping_stats` (admin), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | +| `random` | Pick one comma-separated option at random: `/random` (text), `/wheelofnames` (wheel GIF), `/gacha` (Genshin-style wish; options are 5★ by default, prefix `4*` or `3*`), `/gachabeta` (unlisted; toon-shaded beta wish with S/SS/SSS ranks). The animations use the optional wheelofnames renderer | | `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only | | `wordle` | Daily Wordle game: `/wordle [word]`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | | `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) | @@ -25,7 +26,8 @@ Atlas via long polling and an in-process cron scheduler. Commands marked (admin) require a user ID in `ADMIN_IDS` or the owner; commands marked (owner) require `OWNER_ID`. Both kinds are hidden from `/help` and the -Telegram command menu. Every other command is public. +Telegram command menu. Commands marked (unlisted) can be run by anyone but are +also hidden from `/help` and the menu. Every other command is public. Choose modules with the `MODULES` environment variable, a comma-separated list; empty loads every module. diff --git a/cmd/server/main.go b/cmd/server/main.go index 69cc890..20a2a1d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,6 +31,7 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/loldle" "github.com/tiennm99/miti99bot/internal/modules/misc" "github.com/tiennm99/miti99bot/internal/modules/monkeyd" + "github.com/tiennm99/miti99bot/internal/modules/random" "github.com/tiennm99/miti99bot/internal/modules/stats" "github.com/tiennm99/miti99bot/internal/modules/sticker" "github.com/tiennm99/miti99bot/internal/modules/stock" @@ -92,6 +93,7 @@ func factories() map[string]modules.Factory { return map[string]modules.Factory{ "util": util.New, "misc": misc.New, + "random": random.New, "amlich": amlich.New, "monkeyd": monkeyd.New, "wordle": wordle.New, diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 056713a..246a35c 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -93,6 +93,11 @@ option is equally likely, as with `/random`; the rarity only sets what the animation and reply show. Options are 5★ by default; prefix `4*` or `3*` to lower one, e.g. `/gacha Pizza, 4* Pho, 3* Rice`. +The unlisted `/gachabeta` takes the same input and renders the 8-second beta +style from `.../api/gachabeta` on the same service (a toon-shaded sky, a comet +bursting through a cloud, and an `S`/`SS`/`SSS` rank for 3★/4★/5★), with the +same text fallback. + ## 1. MongoDB Atlas (M0) 1. Create a free **M0** cluster (512 MB — ample for the tiny paper-trading KV). diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 0a31c83..5183e83 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -2,11 +2,7 @@ package misc import ( "context" - "encoding/json" "fmt" - "net/http" - "net/http/httptest" - "slices" "strings" "testing" "time" @@ -94,280 +90,6 @@ func TestPingStats_DeniedToNonAdmin(t *testing.T) { } } -func TestRandom_UsageWhenMissingOptions(t *testing.T) { - for _, text := range []string{"/random", "/random , ,"} { - t.Run(text, func(t *testing.T) { - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) - - if got := rb.LastSent().Text(); got != randomUsage { - t.Errorf("random reply = %q, want usage %q", got, randomUsage) - } - }) - } -} - -func TestRandom_SingleOption(t *testing.T) { - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random Alice")) - - if got := rb.LastSent().Text(); got != "Alice" { - t.Errorf("random reply = %q, want Alice", got) - } -} - -func TestRandom_PicksFromTrimmedOptions(t *testing.T) { - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random Alice, Bob, Carol")) - - got := rb.LastSent().Text() - if got != "Alice" && got != "Bob" && got != "Carol" { - t.Errorf("random reply = %q, want one of Alice/Bob/Carol", got) - } -} - -func TestRandom_IgnoresEmptySegments(t *testing.T) { - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random , Alice , , Bob ,")) - - got := rb.LastSent().Text() - if got != "Alice" && got != "Bob" { - t.Errorf("random reply = %q, want Alice or Bob", got) - } -} - -func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) { - for _, text := range []string{"/wheelofnames", "/wheelofnames , ,"} { - t.Run(text, func(t *testing.T) { - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) - - if got := rb.LastSent().Text(); got != wheelUsage { - t.Errorf("wheelofnames reply = %q, want usage %q", got, wheelUsage) - } - }) - } -} - -func TestWheelOfNames_ResultCaptionEscapesHTML(t *testing.T) { - got := wheelResultCaption([]string{``}, 0) - want := `Result: <Alice & Bob>` - if got != want { - t.Fatalf("wheelResultCaption() = %q, want %q", got, want) - } -} - -func TestWheelOfNames_ResultCaptionTruncatesLongResult(t *testing.T) { - got := wheelResultCaption([]string{strings.Repeat("a", wheelResultCaptionMaxRunes+1)}, 0) - want := `Result: ` + strings.Repeat("a", wheelResultCaptionMaxRunes) + `...` - if got != want { - t.Fatalf("wheelResultCaption() length = %d, want truncated caption length %d", len(got), len(want)) - } -} - -func TestWheelOfNames_ResultCaptionPadsShortWinnerToLongestOption(t *testing.T) { - options := []string{"Bob", "Alexandria"} - got := wheelResultCaption(options, 0) - want := `Result: ___Bob____` - if got != want { - t.Fatalf("wheelResultCaption() = %q, want %q", got, want) - } - if longest := wheelResultCaption(options, 1); len([]rune(longest)) != len([]rune(got)) { - t.Fatalf("caption lengths differ: winner %q vs %q", got, longest) - } -} - -func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) { - var got wheelAPIRequest - var gotAuthorization string - var calls int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - if r.URL.Path != "/api/gif" { - t.Errorf("path = %q, want /api/gif", r.URL.Path) - } - gotAuthorization = r.Header.Get("Authorization") - if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Errorf("Decode request body: %v", err) - } - w.Header().Set("Content-Type", "image/gif") - _, _ = w.Write([]byte("GIF89a-remote")) - })) - defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - t.Setenv(wheelOfNamesAPITokenEnv, "remote-token") - - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol")) - - if calls != 1 { - t.Fatalf("remote calls = %d, want 1", calls) - } - if gotAuthorization != "Bearer remote-token" { - t.Fatalf("Authorization = %q, want bearer token", gotAuthorization) - } - if !slices.Equal(got.Options, []string{"Alice", "Bob", "Carol"}) { - t.Fatalf("options = %#v, want parsed options", got.Options) - } - if got.WinnerIndex < 0 || got.WinnerIndex >= len(got.Options) { - t.Fatalf("winnerIndex = %d, want in range", got.WinnerIndex) - } - assertWheelRemoteDefaults(t, got) - - sent := rb.Sent() - if len(sent) != 3 { - t.Fatalf("calls = %+v, want placeholder, sendAnimation, deleteMessage", sent) - } - if sent[0].Method != "sendMessage" || sent[0].Text() != wheelPlaceholder { - t.Fatalf("first call = %+v, want placeholder %q", sent[0], wheelPlaceholder) - } - // The placeholder cannot be edited into media, so it is dropped once the - // animation lands and the animation itself carries the result. - if sent[2].Method != "deleteMessage" || sent[2].Form["message_id"] != "1" { - t.Fatalf("last call = %+v, want deleteMessage of the placeholder", sent[2]) - } - call := sent[1] - if call.Method != "sendAnimation" { - t.Fatalf("method = %q, want sendAnimation", call.Method) - } - wantCaption := wheelResultCaption(got.Options, got.WinnerIndex) - if got := call.Form["caption"]; got != wantCaption { - t.Fatalf("caption = %q, want %q", got, wantCaption) - } - if got := call.Form["parse_mode"]; got != "HTML" { - t.Fatalf("parse_mode = %q, want HTML", got) - } - if got := call.Form["duration"]; got != "7" { - t.Fatalf("duration = %q, want 7", got) - } - if got := call.Form["width"]; got != "512" { - t.Fatalf("width = %q, want 512", got) - } - if got := call.Form["height"]; got != "512" { - t.Fatalf("height = %q, want 512", got) - } -} - -func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) { - var calls int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - http.Error(w, "no", http.StatusInternalServerError) - })) - defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - t.Setenv(wheelOfNamesAPITokenEnv, "remote-token") - - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) - - if calls != 1 { - t.Fatalf("remote calls = %d, want 1", calls) - } - sent := rb.Sent() - if len(sent) != 2 { - t.Fatalf("calls = %+v, want placeholder then in-place edit", sent) - } - if sent[0].Method != "sendMessage" || sent[0].Text() != wheelPlaceholder { - t.Fatalf("first call = %+v, want placeholder %q", sent[0], wheelPlaceholder) - } - if sent[1].Method != "editMessageText" || sent[1].Text() != "Alice" { - t.Fatalf("fallback call = %+v, want editMessageText Alice", sent[1]) - } - if got := sent[1].Form["message_id"]; got != "1" { - t.Fatalf("edited message_id = %q, want the placeholder id 1", got) - } -} - -// A failed edit still has to deliver the winner, so the handler falls back to a -// fresh reply rather than leaving "Spinning..." on screen forever. -func TestWheelOfNames_PlaceholderEditFailureFallsBackToReply(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "no", http.StatusInternalServerError) - })) - defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - - rb, _ := installMisc(t, 999) - rb.FailMethod("editMessageText", http.StatusInternalServerError, "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) - - call := rb.LastSent() - if call.Method != "sendMessage" || call.Text() != "Alice" { - t.Fatalf("fallback call = %+v, want sendMessage Alice", call) - } -} - -// Without a renderer there is nothing to wait for, so the winner must land -// straight away with no placeholder flashing before it. -func TestWheelOfNames_NotConfiguredFallsBackToRandomReply(t *testing.T) { - rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) - - calls := rb.Sent() - if len(calls) != 1 { - t.Fatalf("calls = %+v, want a single winner reply", calls) - } - if calls[0].Method != "sendMessage" || calls[0].Text() != "Alice" { - t.Fatalf("fallback call = %+v, want sendMessage Alice", calls[0]) - } -} - -func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/gif") - _, _ = w.Write([]byte("GIF89a-remote")) - })) - defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - - rb, _ := installMisc(t, 999) - rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) - - calls := rb.Sent() - if len(calls) != 3 { - t.Fatalf("calls = %+v, want placeholder, sendAnimation, in-place edit", calls) - } - if calls[0].Method != "sendMessage" || calls[0].Text() != wheelPlaceholder { - t.Fatalf("first call = %+v, want placeholder %q", calls[0], wheelPlaceholder) - } - if calls[1].Method != "sendAnimation" { - t.Fatalf("second method = %q, want sendAnimation", calls[1].Method) - } - // The placeholder survives a failed animation precisely so the winner can - // replace it instead of stranding "Spinning..." in the chat. - if calls[2].Method != "editMessageText" || calls[2].Text() != "Alice" { - t.Fatalf("fallback call = %+v, want editMessageText Alice", calls[2]) - } -} - -func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/gif") - _, _ = w.Write([]byte("GIF89a-remote")) - })) - defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - - rb, _ := installMisc(t, 999) - update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice") - update.Message.MessageThreadID = 42 - rb.Bot.ProcessUpdate(context.Background(), update) - - for _, call := range rb.Sent() { - switch call.Method { - case "sendMessage", "sendAnimation": - if got := call.Form["message_thread_id"]; got != "42" { - t.Fatalf("%s message_thread_id = %q, want 42", call.Method, got) - } - } - } - if got := rb.Sent()[1].Method; got != "sendAnimation" { - t.Fatalf("second method = %q, want sendAnimation", got) - } -} - // messageFrom is the inline counterpart of testutil.NewPrivateMessage // for cases that need control over From (username, names). The dispatcher // requires a bot_command entity, so we lift that from the helper API by reusing diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index 0b94ce4..34ed3bc 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -1,8 +1,6 @@ // Package misc is a small module that proves the framework end-to-end: // /ping (public, exercises a store write), /ping_stats (protected, exercises a -// store read), /random (public random picker), /wheelofnames (public wheel -// picker with optional GIF), /gacha (public Genshin-style wish picker with -// optional MP4), /ff (protected give-up-on-T1 rant), /xlt1 (public +// store read), /ff (protected give-up-on-T1 rant), /xlt1 (public // apologise-to-T1 petition, the sequel to /ff), /giaxang (public Petrolimex // retail fuel prices), /the_answer (private easter egg), and the public // disclaimer commands /trongtruonghop, /tth, /trongtruonghopvng, and /tthvng. @@ -55,9 +53,6 @@ func New(deps modules.Deps) modules.Module { Commands: []modules.Command{ pingCommand(store), pingStatsCommand(store), - randomCommand(), - wheelOfNamesCommand(), - gachaCommand(), ffCommand(), xlt1Command(), giaxangCommand(), diff --git a/internal/modules/misc/misc_test.go b/internal/modules/misc/misc_test.go index dc06e6d..f3c71a2 100644 --- a/internal/modules/misc/misc_test.go +++ b/internal/modules/misc/misc_test.go @@ -23,9 +23,6 @@ func TestNew_RegistersExpectedCommands(t *testing.T) { want := map[string]modules.Visibility{ "ping": modules.VisibilityPublic, "ping_stats": modules.VisibilityProtected, - "random": modules.VisibilityPublic, - "wheelofnames": modules.VisibilityPublic, - "gacha": modules.VisibilityPublic, "ff": modules.VisibilityProtected, "xlt1": modules.VisibilityPublic, "giaxang": modules.VisibilityPublic, diff --git a/internal/modules/random/gacha_api_client.go b/internal/modules/random/gacha_api_client.go index 1ce9b13..554d994 100644 --- a/internal/modules/random/gacha_api_client.go +++ b/internal/modules/random/gacha_api_client.go @@ -1,4 +1,4 @@ -package misc +package random import ( "bytes" @@ -10,10 +10,21 @@ import ( ) const ( - gachaRemoteFPS = 24 - gachaRemoteWidth = 640 - gachaRemoteHeight = 360 - gachaRemoteDuration = 7 + gachaRemoteFPS = 24 + gachaRemoteWidth = 640 + gachaRemoteHeight = 360 +) + +// gachaStyle is one wish animation the renderer serves: its path next to +// /api/gif and the clip length in seconds. +type gachaStyle struct { + Path string + Duration int +} + +var ( + gachaStyleWish = gachaStyle{Path: "gacha", Duration: 7} + gachaStyleBeta = gachaStyle{Path: "gachabeta", Duration: 8} ) type gachaAPIRequest struct { @@ -23,10 +34,11 @@ type gachaAPIRequest struct { Width int `json:"width"` } -// gachaAPIEndpoint derives the wish renderer from the configured wheel -// endpoint: the same service serves /api/gacha next to /api/gif, so the last -// path segment is swapped and no second URL needs configuring. -func gachaAPIEndpoint(rawURL string) (*url.URL, error) { +// gachaAPIEndpoint derives a wish renderer from the configured wheel +// endpoint: the same service serves /api/gacha and /api/gachabeta next to +// /api/gif, so the last path segment is swapped and no second URL needs +// configuring. +func gachaAPIEndpoint(rawURL, path string) (*url.URL, error) { endpoint, err := wheelAPIEndpoint(rawURL) if err != nil { return nil, err @@ -34,12 +46,13 @@ func gachaAPIEndpoint(rawURL string) (*url.URL, error) { base := *endpoint base.Path = strings.TrimSuffix(base.Path, "/") base.RawPath = "" - return base.ResolveReference(&url.URL{Path: "gacha"}), nil + return base.ResolveReference(&url.URL{Path: path}), nil } -// RenderGacha returns the MP4 wish animation revealing label at rarity stars. -func (c wheelAPIClient) RenderGacha(ctx context.Context, label string, rarity int) ([]byte, error) { - endpoint, err := gachaAPIEndpoint(c.URL) +// RenderGacha returns the MP4 wish animation at path revealing label at +// rarity stars. +func (c wheelAPIClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) { + endpoint, err := gachaAPIEndpoint(c.URL, path) if err != nil { return nil, err } @@ -66,14 +79,14 @@ func isMP4(data []byte) bool { return len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp")) } -func renderGachaAnimation(ctx context.Context, label string, rarity int) (wheelAnimation, error) { - data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, label, rarity) +func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (wheelAnimation, error) { + data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, style.Path, label, rarity) if err != nil { return wheelAnimation{}, err } return wheelAnimation{ Data: data, - Duration: gachaRemoteDuration, + Duration: style.Duration, Width: gachaRemoteWidth, Height: gachaRemoteHeight, }, nil diff --git a/internal/modules/random/gacha_api_client_test.go b/internal/modules/random/gacha_api_client_test.go index 1a0b738..90a3f40 100644 --- a/internal/modules/random/gacha_api_client_test.go +++ b/internal/modules/random/gacha_api_client_test.go @@ -1,4 +1,4 @@ -package misc +package random import ( "context" @@ -13,7 +13,7 @@ func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) { "https://render.example/api/gif/": "https://render.example/api/gacha", "http://10.0.0.5:3000/api/gif": "http://10.0.0.5:3000/api/gacha", } { - got, err := gachaAPIEndpoint(raw) + got, err := gachaAPIEndpoint(raw, "gacha") if err != nil { t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err) } @@ -21,7 +21,7 @@ func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) { t.Errorf("gachaAPIEndpoint(%q) = %q, want %q", raw, got, want) } } - if _, err := gachaAPIEndpoint(""); err != errWheelAPINotConfigured { + if _, err := gachaAPIEndpoint("", "gacha"); err != errWheelAPINotConfigured { t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err) } } @@ -38,7 +38,7 @@ func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) { {name: "rarity too high", label: "a", rarity: 6}, } { t.Run(tc.name, func(t *testing.T) { - if _, err := client.RenderGacha(context.Background(), tc.label, tc.rarity); err == nil { + if _, err := client.RenderGacha(context.Background(), "gacha", tc.label, tc.rarity); err == nil { t.Fatal("RenderGacha returned nil error") } }) @@ -66,7 +66,7 @@ func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) { defer server.Close() client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} - if _, err := client.RenderGacha(context.Background(), "a", 3); err == nil { + if _, err := client.RenderGacha(context.Background(), "gacha", "a", 3); err == nil { t.Fatal("RenderGacha returned nil error") } }) diff --git a/internal/modules/random/gacha_command.go b/internal/modules/random/gacha_command.go index d7ba7ad..b7d9d4c 100644 --- a/internal/modules/random/gacha_command.go +++ b/internal/modules/random/gacha_command.go @@ -1,4 +1,4 @@ -package misc +package random import ( "bytes" @@ -18,6 +18,7 @@ import ( const ( gachaUsage = "Usage: /gacha \nOptions are 5* by default; prefix 4* or 3* to lower one, e.g. /gacha Pizza, 4* Pho, 3* Rice" + gachaBetaUsage = "Usage: /gachabeta \nOptions are 5* by default; prefix 4* or 3* to lower one, e.g. /gachabeta Pizza, 4* Pho, 3* Rice" gachaPlaceholder = "Wishing..." gachaFilename = "gacha.mp4" @@ -59,10 +60,26 @@ func gachaResultText(option gachaOption) string { } func gachaCommand() modules.Command { + return newGachaCommand("gacha", modules.VisibilityPublic, + "Wish for one comma-separated option, Genshin style; 5* by default, prefix 4* or 3*", + gachaUsage, gachaStyleWish) +} + +// gachaBetaCommand trials the toon-shaded beta wish. It is unlisted: anyone +// can run it, but it stays out of the command menu and /help. +func gachaBetaCommand() modules.Command { + return newGachaCommand("gachabeta", modules.VisibilityUnlisted, + "Beta wish: toon sky, comet through a cloud, S/SS/SSS rank; 5* by default, prefix 4* or 3*", + gachaBetaUsage, gachaStyleBeta) +} + +// newGachaCommand builds a wish command that picks one option and renders it +// in style, falling back to a text reply when rendering fails. +func newGachaCommand(name string, visibility modules.Visibility, description, usage string, style gachaStyle) modules.Command { return modules.Command{ - Name: "gacha", - Visibility: modules.VisibilityPublic, - Description: "Wish for one comma-separated option, Genshin style; 5* by default, prefix 4* or 3*", + Name: name, + Visibility: visibility, + Description: description, Parameters: "", Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { if update.Message == nil { @@ -70,7 +87,7 @@ func gachaCommand() modules.Command { } options := parseGachaOptions(chathelper.ArgAfterCommand(update.Message.Text)) if len(options) == 0 { - return chathelper.Reply(ctx, b, update.Message, gachaUsage) + return chathelper.Reply(ctx, b, update.Message, usage) } // Every option is equally likely; the rarity tag only styles the // wish animation and the result text. @@ -81,10 +98,10 @@ func gachaCommand() modules.Command { } placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder) - animation, err := renderGachaAnimation(ctx, options[winner].Label, options[winner].Rarity) + animation, err := renderGachaAnimation(ctx, style, options[winner].Label, options[winner].Rarity) if err != nil { if !errors.Is(err, errWheelAPINotConfigured) { - log.Warn("gacha remote render failed", "err", err) + log.Warn("gacha remote render failed", "command", name, "err", err) } return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) } @@ -102,11 +119,11 @@ func gachaCommand() modules.Command { ParseMode: models.ParseModeHTML, }) if err != nil { - log.Warn("gacha send animation failed", "chat", update.Message.Chat.ID, "err", err) + log.Warn("gacha send animation failed", "command", name, "chat", update.Message.Chat.ID, "err", err) return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) } if err := chathelper.DeleteMessage(ctx, b, update.Message, placeholder); err != nil { - log.Warn("gacha placeholder delete failed", "chat", update.Message.Chat.ID, "err", err) + log.Warn("gacha placeholder delete failed", "command", name, "chat", update.Message.Chat.ID, "err", err) } return nil }, diff --git a/internal/modules/random/gacha_command_test.go b/internal/modules/random/gacha_command_test.go index 500f6ac..b9af0cd 100644 --- a/internal/modules/random/gacha_command_test.go +++ b/internal/modules/random/gacha_command_test.go @@ -1,4 +1,4 @@ -package misc +package random import ( "context" @@ -33,7 +33,7 @@ func TestParseGachaOptions(t *testing.T) { func TestGacha_EmptyArgsRepliesUsage(t *testing.T) { for _, text := range []string{"/gacha", "/gacha , ,", "/gacha 5*"} { t.Run(text, func(t *testing.T) { - rb, _ := installMisc(t, 999) + rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) if got := rb.LastSent().Text(); got != gachaUsage { @@ -44,7 +44,7 @@ func TestGacha_EmptyArgsRepliesUsage(t *testing.T) { } func TestGacha_NotConfiguredRepliesWithStars(t *testing.T) { - rb, _ := installMisc(t, 999) + rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha Pizza")) calls := rb.Sent() @@ -69,7 +69,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) { t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(wheelOfNamesAPITokenEnv, "remote-token") - rb, _ := installMisc(t, 999) + rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 4* Pho")) if gotPath != "/api/gacha" { @@ -114,7 +114,7 @@ func TestGacha_RemoteFailureFallsBackToText(t *testing.T) { defer server.Close() t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - rb, _ := installMisc(t, 999) + rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice")) sent := rb.Sent() @@ -134,7 +134,7 @@ func TestGacha_SendAnimationFailureFallsBackToText(t *testing.T) { defer server.Close() t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - rb, _ := installMisc(t, 999) + rb := installRandom(t, 999) rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice")) @@ -152,7 +152,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) { defer server.Close() t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") - rb, _ := installMisc(t, 999) + rb := installRandom(t, 999) update := testutil.NewSupergroupMessage(-100, 7, "/gacha 3* Rice") update.Message.MessageThreadID = 42 rb.Bot.ProcessUpdate(context.Background(), update) @@ -173,3 +173,51 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) { t.Fatalf("sendAnimation calls = %d, want 1", animations) } } + +func TestGachaBeta_UsesBetaRendererAndAnyoneCanRunIt(t *testing.T) { + var gotPath string + var got gachaAPIRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("Decode request body: %v", err) + } + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(mp4Bytes) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + + // 7 is neither the owner (999) nor an admin. + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gachabeta Bún bò")) + + if gotPath != "/api/gachabeta" { + t.Fatalf("path = %q, want /api/gachabeta", gotPath) + } + if want := (gachaAPIRequest{Label: "Bún bò", Rarity: 5, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}); got != want { + t.Fatalf("request = %+v, want %+v", got, want) + } + var animation *testutil.SentCall + sent := rb.Sent() + for i := range sent { + if sent[i].Method == "sendAnimation" { + animation = &sent[i] + } + } + if animation == nil { + t.Fatalf("calls = %+v, want a sendAnimation", rb.Sent()) + } + if got := animation.Form["duration"]; got != "8" { + t.Fatalf("duration = %q, want 8", got) + } +} + +func TestGachaBeta_EmptyArgsRepliesBetaUsage(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gachabeta")) + + if got := rb.LastSent().Text(); got != gachaBetaUsage { + t.Errorf("gachabeta reply = %q, want usage %q", got, gachaBetaUsage) + } +} diff --git a/internal/modules/random/handlers_test.go b/internal/modules/random/handlers_test.go new file mode 100644 index 0000000..d6e1b10 --- /dev/null +++ b/internal/modules/random/handlers_test.go @@ -0,0 +1,306 @@ +package random + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +// installRandom wires the random module to a recording bot with a fresh +// in-memory store and an Auth whose owner is ownerID. +func installRandom(t *testing.T, ownerID int64) *testutil.RecordingBot { + t.Helper() + rb := testutil.NewRecordingBot(t) + mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("random")}) + reg := &modules.Registry{ + Modules: []modules.Module{{Name: "random", Commands: mod.Commands}}, + AllCommands: map[string]modules.Command{}, + } + for _, c := range mod.Commands { + reg.AllCommands[c.Name] = c + } + modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID}) + return rb +} + +func TestRandom_UsageWhenMissingOptions(t *testing.T) { + for _, text := range []string{"/random", "/random , ,"} { + t.Run(text, func(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + + if got := rb.LastSent().Text(); got != randomUsage { + t.Errorf("random reply = %q, want usage %q", got, randomUsage) + } + }) + } +} + +func TestRandom_SingleOption(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random Alice")) + + if got := rb.LastSent().Text(); got != "Alice" { + t.Errorf("random reply = %q, want Alice", got) + } +} + +func TestRandom_PicksFromTrimmedOptions(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random Alice, Bob, Carol")) + + got := rb.LastSent().Text() + if got != "Alice" && got != "Bob" && got != "Carol" { + t.Errorf("random reply = %q, want one of Alice/Bob/Carol", got) + } +} + +func TestRandom_IgnoresEmptySegments(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random , Alice , , Bob ,")) + + got := rb.LastSent().Text() + if got != "Alice" && got != "Bob" { + t.Errorf("random reply = %q, want Alice or Bob", got) + } +} + +func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) { + for _, text := range []string{"/wheelofnames", "/wheelofnames , ,"} { + t.Run(text, func(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + + if got := rb.LastSent().Text(); got != wheelUsage { + t.Errorf("wheelofnames reply = %q, want usage %q", got, wheelUsage) + } + }) + } +} + +func TestWheelOfNames_ResultCaptionEscapesHTML(t *testing.T) { + got := wheelResultCaption([]string{``}, 0) + want := `Result: <Alice & Bob>` + if got != want { + t.Fatalf("wheelResultCaption() = %q, want %q", got, want) + } +} + +func TestWheelOfNames_ResultCaptionTruncatesLongResult(t *testing.T) { + got := wheelResultCaption([]string{strings.Repeat("a", wheelResultCaptionMaxRunes+1)}, 0) + want := `Result: ` + strings.Repeat("a", wheelResultCaptionMaxRunes) + `...` + if got != want { + t.Fatalf("wheelResultCaption() length = %d, want truncated caption length %d", len(got), len(want)) + } +} + +func TestWheelOfNames_ResultCaptionPadsShortWinnerToLongestOption(t *testing.T) { + options := []string{"Bob", "Alexandria"} + got := wheelResultCaption(options, 0) + want := `Result: ___Bob____` + if got != want { + t.Fatalf("wheelResultCaption() = %q, want %q", got, want) + } + if longest := wheelResultCaption(options, 1); len([]rune(longest)) != len([]rune(got)) { + t.Fatalf("caption lengths differ: winner %q vs %q", got, longest) + } +} + +func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) { + var got wheelAPIRequest + var gotAuthorization string + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/api/gif" { + t.Errorf("path = %q, want /api/gif", r.URL.Path) + } + gotAuthorization = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("Decode request body: %v", err) + } + w.Header().Set("Content-Type", "image/gif") + _, _ = w.Write([]byte("GIF89a-remote")) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(wheelOfNamesAPITokenEnv, "remote-token") + + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol")) + + if calls != 1 { + t.Fatalf("remote calls = %d, want 1", calls) + } + if gotAuthorization != "Bearer remote-token" { + t.Fatalf("Authorization = %q, want bearer token", gotAuthorization) + } + if !slices.Equal(got.Options, []string{"Alice", "Bob", "Carol"}) { + t.Fatalf("options = %#v, want parsed options", got.Options) + } + if got.WinnerIndex < 0 || got.WinnerIndex >= len(got.Options) { + t.Fatalf("winnerIndex = %d, want in range", got.WinnerIndex) + } + assertWheelRemoteDefaults(t, got) + + sent := rb.Sent() + if len(sent) != 3 { + t.Fatalf("calls = %+v, want placeholder, sendAnimation, deleteMessage", sent) + } + if sent[0].Method != "sendMessage" || sent[0].Text() != wheelPlaceholder { + t.Fatalf("first call = %+v, want placeholder %q", sent[0], wheelPlaceholder) + } + // The placeholder cannot be edited into media, so it is dropped once the + // animation lands and the animation itself carries the result. + if sent[2].Method != "deleteMessage" || sent[2].Form["message_id"] != "1" { + t.Fatalf("last call = %+v, want deleteMessage of the placeholder", sent[2]) + } + call := sent[1] + if call.Method != "sendAnimation" { + t.Fatalf("method = %q, want sendAnimation", call.Method) + } + wantCaption := wheelResultCaption(got.Options, got.WinnerIndex) + if got := call.Form["caption"]; got != wantCaption { + t.Fatalf("caption = %q, want %q", got, wantCaption) + } + if got := call.Form["parse_mode"]; got != "HTML" { + t.Fatalf("parse_mode = %q, want HTML", got) + } + if got := call.Form["duration"]; got != "7" { + t.Fatalf("duration = %q, want 7", got) + } + if got := call.Form["width"]; got != "512" { + t.Fatalf("width = %q, want 512", got) + } + if got := call.Form["height"]; got != "512" { + t.Fatalf("height = %q, want 512", got) + } +} + +func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) { + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + http.Error(w, "no", http.StatusInternalServerError) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(wheelOfNamesAPITokenEnv, "remote-token") + + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + if calls != 1 { + t.Fatalf("remote calls = %d, want 1", calls) + } + sent := rb.Sent() + if len(sent) != 2 { + t.Fatalf("calls = %+v, want placeholder then in-place edit", sent) + } + if sent[0].Method != "sendMessage" || sent[0].Text() != wheelPlaceholder { + t.Fatalf("first call = %+v, want placeholder %q", sent[0], wheelPlaceholder) + } + if sent[1].Method != "editMessageText" || sent[1].Text() != "Alice" { + t.Fatalf("fallback call = %+v, want editMessageText Alice", sent[1]) + } + if got := sent[1].Form["message_id"]; got != "1" { + t.Fatalf("edited message_id = %q, want the placeholder id 1", got) + } +} + +// A failed edit still has to deliver the winner, so the handler falls back to a +// fresh reply rather than leaving "Spinning..." on screen forever. +func TestWheelOfNames_PlaceholderEditFailureFallsBackToReply(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no", http.StatusInternalServerError) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + + rb := installRandom(t, 999) + rb.FailMethod("editMessageText", http.StatusInternalServerError, "") + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + call := rb.LastSent() + if call.Method != "sendMessage" || call.Text() != "Alice" { + t.Fatalf("fallback call = %+v, want sendMessage Alice", call) + } +} + +// Without a renderer there is nothing to wait for, so the winner must land +// straight away with no placeholder flashing before it. +func TestWheelOfNames_NotConfiguredFallsBackToRandomReply(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + calls := rb.Sent() + if len(calls) != 1 { + t.Fatalf("calls = %+v, want a single winner reply", calls) + } + if calls[0].Method != "sendMessage" || calls[0].Text() != "Alice" { + t.Fatalf("fallback call = %+v, want sendMessage Alice", calls[0]) + } +} + +func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/gif") + _, _ = w.Write([]byte("GIF89a-remote")) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + + rb := installRandom(t, 999) + rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + calls := rb.Sent() + if len(calls) != 3 { + t.Fatalf("calls = %+v, want placeholder, sendAnimation, in-place edit", calls) + } + if calls[0].Method != "sendMessage" || calls[0].Text() != wheelPlaceholder { + t.Fatalf("first call = %+v, want placeholder %q", calls[0], wheelPlaceholder) + } + if calls[1].Method != "sendAnimation" { + t.Fatalf("second method = %q, want sendAnimation", calls[1].Method) + } + // The placeholder survives a failed animation precisely so the winner can + // replace it instead of stranding "Spinning..." in the chat. + if calls[2].Method != "editMessageText" || calls[2].Text() != "Alice" { + t.Fatalf("fallback call = %+v, want editMessageText Alice", calls[2]) + } +} + +func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/gif") + _, _ = w.Write([]byte("GIF89a-remote")) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + + rb := installRandom(t, 999) + update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice") + update.Message.MessageThreadID = 42 + rb.Bot.ProcessUpdate(context.Background(), update) + + for _, call := range rb.Sent() { + switch call.Method { + case "sendMessage", "sendAnimation": + if got := call.Form["message_thread_id"]; got != "42" { + t.Fatalf("%s message_thread_id = %q, want 42", call.Method, got) + } + } + } + if got := rb.Sent()[1].Method; got != "sendAnimation" { + t.Fatalf("second method = %q, want sendAnimation", got) + } +} diff --git a/internal/modules/random/random.go b/internal/modules/random/random.go new file mode 100644 index 0000000..f09641c --- /dev/null +++ b/internal/modules/random/random.go @@ -0,0 +1,20 @@ +// Package random groups the commands that pick one option at random: /random +// (plain text pick), /wheelofnames (wheel GIF when a renderer is configured), +// /gacha (Genshin-style wish MP4), and the unlisted /gachabeta (toon-shaded +// beta wish). The animated commands share the optional wheelofnames renderer +// and fall back to a text reply without it. +package random + +import "github.com/tiennm99/miti99bot/internal/modules" + +// New is the module Factory. The commands keep no state, so deps is unused. +func New(_ modules.Deps) modules.Module { + return modules.Module{ + Commands: []modules.Command{ + randomCommand(), + wheelOfNamesCommand(), + gachaCommand(), + gachaBetaCommand(), + }, + } +} diff --git a/internal/modules/random/random_picker.go b/internal/modules/random/random_picker.go index 795d607..5b5038c 100644 --- a/internal/modules/random/random_picker.go +++ b/internal/modules/random/random_picker.go @@ -1,4 +1,4 @@ -package misc +package random import ( "context" diff --git a/internal/modules/random/random_test.go b/internal/modules/random/random_test.go new file mode 100644 index 0000000..5c0c06e --- /dev/null +++ b/internal/modules/random/random_test.go @@ -0,0 +1,35 @@ +package random + +import ( + "testing" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" +) + +func TestNew_RegistersRandomGroup(t *testing.T) { + mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("random")}) + + want := map[string]modules.Visibility{ + "random": modules.VisibilityPublic, + "wheelofnames": modules.VisibilityPublic, + "gacha": modules.VisibilityPublic, + "gachabeta": modules.VisibilityUnlisted, + } + 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) + } + } +} diff --git a/internal/modules/random/wheelofnames_api_client.go b/internal/modules/random/wheelofnames_api_client.go index 5717df4..c1c6c90 100644 --- a/internal/modules/random/wheelofnames_api_client.go +++ b/internal/modules/random/wheelofnames_api_client.go @@ -1,4 +1,4 @@ -package misc +package random import ( "bytes" diff --git a/internal/modules/random/wheelofnames_api_client_test.go b/internal/modules/random/wheelofnames_api_client_test.go index ffa861a..7c2a65a 100644 --- a/internal/modules/random/wheelofnames_api_client_test.go +++ b/internal/modules/random/wheelofnames_api_client_test.go @@ -1,4 +1,4 @@ -package misc +package random import ( "bytes" diff --git a/internal/modules/random/wheelofnames_command.go b/internal/modules/random/wheelofnames_command.go index f68e4e1..ac4246f 100644 --- a/internal/modules/random/wheelofnames_command.go +++ b/internal/modules/random/wheelofnames_command.go @@ -1,4 +1,4 @@ -package misc +package random import ( "bytes"