From 858eaa066b945fff0a2daefc15a8486a11f8bb20 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 13:02:46 +0700 Subject: [PATCH] feat(misc): add /gacha Genshin-style wish picker Options take an optional *4 or *5 rarity tag; untagged options are 3 stars. A pick rolls a tier at Genshin base rates over the tiers present, then an option uniformly within it, so untagged input matches /random. The wish animation renders as MP4 on the wheelofnames service at /api/gacha, with a text fallback when the renderer is unset or fails. --- .env.example | 5 +- README.md | 2 +- cmd/server/command_menu_test.go | 1 + compose.yml | 2 +- docs/deploy-coolify-selfhosted.md | 7 + internal/modules/misc/gacha_api_client.go | 80 +++++++ .../modules/misc/gacha_api_client_test.go | 74 ++++++ internal/modules/misc/gacha_command.go | 146 ++++++++++++ internal/modules/misc/gacha_command_test.go | 223 ++++++++++++++++++ internal/modules/misc/misc.go | 4 +- internal/modules/misc/misc_test.go | 1 + 11 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 internal/modules/misc/gacha_api_client.go create mode 100644 internal/modules/misc/gacha_api_client_test.go create mode 100644 internal/modules/misc/gacha_command.go create mode 100644 internal/modules/misc/gacha_command_test.go diff --git a/.env.example b/.env.example index ec7a8ed..bb2e67d 100644 --- a/.env.example +++ b/.env.example @@ -38,8 +38,9 @@ STICKER_PACK_NAME=miti99_by_miti99bot # Without it every /lol* fetch fails; the stale cache covers ≤60 min. LOL_PANDASCORE_TOKEN= -# Optional /wheelofnames GIF renderer. Standard deployment: -# https://github.com/tiennm99/wheelofnames. Leave blank to fall back to text +# Optional /wheelofnames GIF and /gacha MP4 renderer. Standard deployment: +# https://github.com/tiennm99/wheelofnames. Set the full /api/gif endpoint; +# /gacha calls /api/gacha on the same service. Leave blank to fall back to text # selection. WHEELOFNAMES_API_URL= # Bearer token matching the wheelofnames service API_TOKEN when URL is set. diff --git a/README.md b/README.md index 45db0e0..aea6173 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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`, `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | +| `misc` | `/ping`, `/ping_stats` (admin), `/random`, `/wheelofnames`, `/gacha` (Genshin-style wish; tag `*4`/`*5` for rarity), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | | `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) | diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index ea861f2..4bef2e4 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -67,6 +67,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "gold_topup": "", "gold_buy": "", "gold_sell": "", + "gacha": "", "lol": "[date]", "loldle": "[champion]", "monkeyd_crawl": " [font_size]", diff --git a/compose.yml b/compose.yml index 99a0ad4..fadb23b 100644 --- a/compose.yml +++ b/compose.yml @@ -14,7 +14,7 @@ services: MODULES: ${MODULES} # CSV; empty = all modules OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids - WHEELOFNAMES_API_URL: ${WHEELOFNAMES_API_URL:-} # Optional full /api/gif endpoint + WHEELOFNAMES_API_URL: ${WHEELOFNAMES_API_URL:-} # Optional full /api/gif endpoint (/gacha uses /api/gacha) WHEELOFNAMES_API_TOKEN: ${WHEELOFNAMES_API_TOKEN:-} # Optional bearer token for that service # SOURCE_COMMIT is intentionally not declared here. Coolify provides it # at runtime via its generated env file; declaring it here with Compose diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 0ac14b1..d77c615 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -85,6 +85,13 @@ render or upload failure edits that same message into the plain text winner instead. With no renderer configured there is no holding message — the winner reply is immediate. +`/gacha` uses the same service and token: the bot swaps the URL's last path +segment, so `.../api/gif` becomes `.../api/gacha`. It renders a 7-second +`640x360` silent MP4 wish animation, posts `Wishing...` while it renders, and +falls back to a text reply such as `★★★★★ Pizza` on the same failures. Options +tagged `*4` or `*5` are rarer, following Genshin base rates (5★ 0.6%, 4★ 5.1%, +3★ 94.3%) over the tiers present; untagged options are 3★. + ## 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/gacha_api_client.go b/internal/modules/misc/gacha_api_client.go new file mode 100644 index 0000000..1ce9b13 --- /dev/null +++ b/internal/modules/misc/gacha_api_client.go @@ -0,0 +1,80 @@ +package misc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + "strings" +) + +const ( + gachaRemoteFPS = 24 + gachaRemoteWidth = 640 + gachaRemoteHeight = 360 + gachaRemoteDuration = 7 +) + +type gachaAPIRequest struct { + Label string `json:"label"` + Rarity int `json:"rarity"` + FPS int `json:"fps"` + 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) { + endpoint, err := wheelAPIEndpoint(rawURL) + if err != nil { + return nil, err + } + base := *endpoint + base.Path = strings.TrimSuffix(base.Path, "/") + base.RawPath = "" + return base.ResolveReference(&url.URL{Path: "gacha"}), 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) + if err != nil { + return nil, err + } + if strings.TrimSpace(label) == "" { + return nil, fmt.Errorf("gacha api label empty") + } + if rarity < gachaMinRarity || rarity > gachaMaxRarity { + return nil, fmt.Errorf("gacha api rarity %d out of range", rarity) + } + body, err := json.Marshal(gachaAPIRequest{ + Label: label, + Rarity: rarity, + FPS: gachaRemoteFPS, + Width: gachaRemoteWidth, + }) + if err != nil { + return nil, fmt.Errorf("gacha api request encode failed: %w", err) + } + return c.post(ctx, endpoint, body, "video/mp4", isMP4) +} + +// isMP4 checks for the ISO base media "ftyp" box that opens every MP4. +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) + if err != nil { + return wheelAnimation{}, err + } + return wheelAnimation{ + Data: data, + Duration: gachaRemoteDuration, + Width: gachaRemoteWidth, + Height: gachaRemoteHeight, + }, nil +} diff --git a/internal/modules/misc/gacha_api_client_test.go b/internal/modules/misc/gacha_api_client_test.go new file mode 100644 index 0000000..1a0b738 --- /dev/null +++ b/internal/modules/misc/gacha_api_client_test.go @@ -0,0 +1,74 @@ +package misc + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) { + for raw, want := range map[string]string{ + "https://render.example/api/gif": "https://render.example/api/gacha", + "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) + if err != nil { + t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err) + } + if got.String() != want { + t.Errorf("gachaAPIEndpoint(%q) = %q, want %q", raw, got, want) + } + } + if _, err := gachaAPIEndpoint(""); err != errWheelAPINotConfigured { + t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err) + } +} + +func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) { + client := wheelAPIClient{URL: "https://example.com/api/gif"} + for _, tc := range []struct { + name string + label string + rarity int + }{ + {name: "blank label", label: " ", rarity: 3}, + {name: "rarity too low", label: "a", rarity: 2}, + {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 { + t.Fatal("RenderGacha returned nil error") + } + }) + } +} + +func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) { + for _, tc := range []struct { + name string + status int + contentType string + body []byte + }{ + {name: "server error", status: http.StatusInternalServerError, contentType: "text/plain", body: []byte("bad")}, + {name: "gif instead of mp4", status: http.StatusOK, contentType: "image/gif", body: []byte("GIF89a")}, + {name: "mislabeled mp4", status: http.StatusOK, contentType: "video/mp4", body: []byte("not an mp4")}, + {name: "empty mp4", status: http.StatusOK, contentType: "video/mp4", body: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", tc.contentType) + w.WriteHeader(tc.status) + _, _ = w.Write(tc.body) + })) + defer server.Close() + + client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} + if _, err := client.RenderGacha(context.Background(), "a", 3); err == nil { + t.Fatal("RenderGacha returned nil error") + } + }) + } +} diff --git a/internal/modules/misc/gacha_command.go b/internal/modules/misc/gacha_command.go new file mode 100644 index 0000000..a5f7ee3 --- /dev/null +++ b/internal/modules/misc/gacha_command.go @@ -0,0 +1,146 @@ +package misc + +import ( + "bytes" + "context" + "errors" + "math/rand/v2" + "regexp" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + gachaUsage = "Usage: /gacha \nTag rarity with *4 or *5, e.g. /gacha Pizza*5, Pho*4, Rice" + gachaPlaceholder = "Wishing..." + gachaFilename = "gacha.mp4" + + gachaMinRarity = 3 + gachaMaxRarity = 5 + gachaDefaultRarity = gachaMinRarity +) + +// gachaTierRates are Genshin Impact's base single-wish odds per mille. A +// pick first rolls a tier among the tiers present, then an option uniformly +// within it, so untagged input is a plain uniform pick. +var gachaTierRates = map[int]int{5: 6, 4: 51, 3: 943} + +// gachaRarityTag matches a trailing "*3", "*4", or "*5" rarity tag. +var gachaRarityTag = regexp.MustCompile(`\s*\*\s*([345])$`) + +type gachaOption struct { + Label string + Rarity int +} + +// parseGachaOptions splits comma-separated options and strips each one's +// rarity tag. Untagged options are 3★; options left blank after the tag is +// removed are dropped. +func parseGachaOptions(arg string) []gachaOption { + parts := splitWheelOptions(arg) + out := make([]gachaOption, 0, len(parts)) + for _, part := range parts { + option := gachaOption{Label: part, Rarity: gachaDefaultRarity} + if m := gachaRarityTag.FindStringSubmatchIndex(part); m != nil { + option.Label = strings.TrimSpace(part[:m[0]]) + option.Rarity = int(part[m[2]] - '0') + } + if option.Label != "" { + out = append(out, option) + } + } + return out +} + +// pickGachaOption returns the index of the wished option. rng may be nil to +// use the global source. +func pickGachaOption(options []gachaOption, rng *rand.Rand) int { + intN := rand.IntN + if rng != nil { + intN = rng.IntN + } + byRarity := map[int][]int{} + total := 0 + for i, option := range options { + if len(byRarity[option.Rarity]) == 0 { + total += gachaTierRates[option.Rarity] + } + byRarity[option.Rarity] = append(byRarity[option.Rarity], i) + } + roll := intN(total) + for rarity := gachaMaxRarity; rarity >= gachaMinRarity; rarity-- { + tier := byRarity[rarity] + if len(tier) == 0 { + continue + } + if roll < gachaTierRates[rarity] { + return tier[intN(len(tier))] + } + roll -= gachaTierRates[rarity] + } + return 0 // unreachable: roll < total, the sum of present tier rates. +} + +// gachaResultText renders an option as its stars followed by its label. +func gachaResultText(option gachaOption) string { + return strings.Repeat("★", option.Rarity) + " " + option.Label +} + +func gachaCommand() modules.Command { + return modules.Command{ + Name: "gacha", + Visibility: modules.VisibilityPublic, + Description: "Wish for one comma-separated option, Genshin style; tag *4 or *5 for rarity", + Parameters: "", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + options := parseGachaOptions(chathelper.ArgAfterCommand(update.Message.Text)) + if len(options) == 0 { + return chathelper.Reply(ctx, b, update.Message, gachaUsage) + } + winner := pickGachaOption(options, nil) + results := make([]string, len(options)) + for i, option := range options { + results[i] = gachaResultText(option) + } + + placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder) + animation, err := renderGachaAnimation(ctx, options[winner].Label, options[winner].Rarity) + if err != nil { + if !errors.Is(err, errWheelAPINotConfigured) { + log.Warn("gacha remote render failed", "err", err) + } + return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) + } + _, err = b.SendAnimation(ctx, &bot.SendAnimationParams{ + ChatID: update.Message.Chat.ID, + MessageThreadID: update.Message.MessageThreadID, + Animation: &models.InputFileUpload{ + Filename: gachaFilename, + Data: bytes.NewReader(animation.Data), + }, + Duration: animation.Duration, + Width: animation.Width, + Height: animation.Height, + Caption: wheelResultCaption(results, winner), + ParseMode: models.ParseModeHTML, + }) + if err != nil { + log.Warn("gacha send animation failed", "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) + } + return nil + }, + } +} diff --git a/internal/modules/misc/gacha_command_test.go b/internal/modules/misc/gacha_command_test.go new file mode 100644 index 0000000..846d2ab --- /dev/null +++ b/internal/modules/misc/gacha_command_test.go @@ -0,0 +1,223 @@ +package misc + +import ( + "context" + "encoding/json" + "math/rand/v2" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +var mp4Bytes = []byte("\x00\x00\x00\x18ftypisom-remote") + +func TestParseGachaOptions(t *testing.T) { + got := parseGachaOptions(" Pizza*5, Pho *4 , Rice, Bun * 3, *5, , Cake*7, a*b ") + want := []gachaOption{ + {Label: "Pizza", Rarity: 5}, + {Label: "Pho", Rarity: 4}, + {Label: "Rice", Rarity: 3}, + {Label: "Bun", Rarity: 3}, + {Label: "Cake*7", Rarity: 3}, + {Label: "a*b", Rarity: 3}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseGachaOptions() = %#v, want %#v", got, want) + } +} + +// One option per tier must land near the Genshin base rates. +func TestPickGachaOption_FollowsTierRates(t *testing.T) { + options := []gachaOption{{"five", 5}, {"four", 4}, {"three", 3}} + rng := rand.New(rand.NewPCG(1, 2)) + const rolls = 200_000 + counts := make([]int, len(options)) + for range rolls { + counts[pickGachaOption(options, rng)]++ + } + for i, wantPerMille := range []float64{6, 51, 943} { + got := float64(counts[i]) / rolls * 1000 + if got < wantPerMille*0.85 || got > wantPerMille*1.15 { + t.Errorf("%s rate = %.2f‰, want about %.0f‰", options[i].Label, got, wantPerMille) + } + } +} + +func TestPickGachaOption_UniformWithinTier(t *testing.T) { + options := []gachaOption{{"a", 3}, {"b", 3}, {"c", 3}, {"d", 3}} + rng := rand.New(rand.NewPCG(3, 4)) + counts := make([]int, len(options)) + for range 40_000 { + counts[pickGachaOption(options, rng)]++ + } + for i, n := range counts { + if n < 9_000 || n > 11_000 { + t.Errorf("option %d picked %d times, want about 10000", i, n) + } + } +} + +// Missing tiers do not dilute the odds: with only 4★ and 5★ options, the +// 4★ share is 51/(51+6). +func TestPickGachaOption_RenormalisesPresentTiers(t *testing.T) { + options := []gachaOption{{"five", 5}, {"four", 4}} + rng := rand.New(rand.NewPCG(5, 6)) + five := 0 + const rolls = 100_000 + for range rolls { + if pickGachaOption(options, rng) == 0 { + five++ + } + } + got := float64(five) / rolls + if want := 6.0 / 57; got < want*0.9 || got > want*1.1 { + t.Fatalf("5★ share = %.4f, want about %.4f", got, want) + } +} + +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.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + + if got := rb.LastSent().Text(); got != gachaUsage { + t.Errorf("gacha reply = %q, want usage %q", got, gachaUsage) + } + }) + } +} + +func TestGacha_NotConfiguredRepliesWithStars(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha Pizza*5")) + + calls := rb.Sent() + if len(calls) != 1 || calls[0].Method != "sendMessage" || calls[0].Text() != "★★★★★ Pizza" { + t.Fatalf("calls = %+v, want a single ★★★★★ Pizza reply", calls) + } +} + +func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) { + var got gachaAPIRequest + var gotPath, gotAuthorization string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = 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", "video/mp4") + _, _ = w.Write(mp4Bytes) + })) + 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, "/gacha Pho*4")) + + if gotPath != "/api/gacha" { + t.Fatalf("path = %q, want /api/gacha", gotPath) + } + if gotAuthorization != "Bearer remote-token" { + t.Fatalf("Authorization = %q, want bearer token", gotAuthorization) + } + want := gachaAPIRequest{Label: "Pho", Rarity: 4, FPS: gachaRemoteFPS, Width: gachaRemoteWidth} + if got != want { + t.Fatalf("request = %+v, want %+v", got, want) + } + + sent := rb.Sent() + if len(sent) != 3 { + t.Fatalf("calls = %+v, want placeholder, sendAnimation, deleteMessage", sent) + } + if sent[0].Method != "sendMessage" || sent[0].Text() != gachaPlaceholder { + t.Fatalf("first call = %+v, want placeholder %q", sent[0], gachaPlaceholder) + } + 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) + } + if want := `Result: ★★★★ Pho`; call.Form["caption"] != want { + t.Fatalf("caption = %q, want %q", call.Form["caption"], want) + } + for field, want := range map[string]string{"parse_mode": "HTML", "duration": "7", "width": "640", "height": "360"} { + if got := call.Form[field]; got != want { + t.Fatalf("%s = %q, want %q", field, got, want) + } + } +} + +func TestGacha_RemoteFailureFallsBackToText(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.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha Rice")) + + sent := rb.Sent() + if len(sent) != 2 { + t.Fatalf("calls = %+v, want placeholder then in-place edit", sent) + } + if sent[1].Method != "editMessageText" || sent[1].Text() != "★★★ Rice" { + t.Fatalf("fallback call = %+v, want editMessageText ★★★ Rice", sent[1]) + } +} + +func TestGacha_SendAnimationFailureFallsBackToText(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(mp4Bytes) + })) + 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, "/gacha Rice")) + + call := rb.LastSent() + if call.Method != "editMessageText" || call.Text() != "★★★ Rice" { + t.Fatalf("fallback call = %+v, want editMessageText ★★★ Rice", call) + } +} + +func TestGacha_ForwardsMessageThreadID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(mp4Bytes) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + + rb, _ := installMisc(t, 999) + update := testutil.NewSupergroupMessage(-100, 7, "/gacha Rice") + update.Message.MessageThreadID = 42 + rb.Bot.ProcessUpdate(context.Background(), update) + + animations := 0 + 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 call.Method == "sendAnimation" { + animations++ + } + } + } + if animations != 1 { + t.Fatalf("sendAnimation calls = %d, want 1", animations) + } +} diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index 44c90d3..0b94ce4 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -1,7 +1,8 @@ // 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), /ff (protected give-up-on-T1 rant), /xlt1 (public +// picker with optional GIF), /gacha (public Genshin-style wish picker with +// optional MP4), /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. @@ -56,6 +57,7 @@ func New(deps modules.Deps) modules.Module { 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 819c361..dc06e6d 100644 --- a/internal/modules/misc/misc_test.go +++ b/internal/modules/misc/misc_test.go @@ -25,6 +25,7 @@ func TestNew_RegistersExpectedCommands(t *testing.T) { "ping_stats": modules.VisibilityProtected, "random": modules.VisibilityPublic, "wheelofnames": modules.VisibilityPublic, + "gacha": modules.VisibilityPublic, "ff": modules.VisibilityProtected, "xlt1": modules.VisibilityPublic, "giaxang": modules.VisibilityPublic,