From 53f387c576455c4a254a62bd88a8038201fe2d80 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 23:38:21 +0700 Subject: [PATCH] feat(random): add an unlisted /genshin meteor wish command /genshin takes the same input as /gacha and sends the Genshin-style meteor wish that wheelofnames serves on /api/genshin, as a 7-second 640x360 MP4. It stays out of the command menu and /help. --- README.md | 2 +- docs/deploy-coolify-selfhosted.md | 4 ++ internal/modules/random/gacha_api_client.go | 53 ++++++++++++------- .../modules/random/gacha_api_client_test.go | 8 +-- internal/modules/random/gacha_command.go | 35 ++++++++---- internal/modules/random/gacha_command_test.go | 50 +++++++++++++++++ internal/modules/random/random.go | 6 ++- internal/modules/random/random_test.go | 1 + 8 files changed, 123 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 221b768..2859e5e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Atlas via long polling and an in-process cron scheduler. |---|---| | `util` | `/help`, `/info` (admin), `/stickerid` (owner) | | `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` (card-pack wish MP4; options are 5★ by default, prefix `4*` or `3*`). The animations use the optional wheelofnames renderer | +| `random` | Pick one comma-separated option at random: `/random` (text), `/wheelofnames` (wheel GIF), `/gacha` (card-pack wish MP4; options are 5★ by default, prefix `4*` or `3*`), `/genshin` (unlisted; the same wish as a Genshin-style meteor). 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) | diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 45af681..6f4337e 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -93,6 +93,10 @@ 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 `/genshin` takes the same input and renders the Genshin-style +meteor wish from `.../api/genshin` on the same service as a 7-second `640x360` +MP4, 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/random/gacha_api_client.go b/internal/modules/random/gacha_api_client.go index b20ef01..a7d5314 100644 --- a/internal/modules/random/gacha_api_client.go +++ b/internal/modules/random/gacha_api_client.go @@ -10,12 +10,25 @@ import ( ) const ( - gachaRemoteFPS = 24 - // gachaRemoteWidth is the requested long edge; the wish is portrait, so - // the video is gachaVideoWidth x gachaRemoteWidth. - gachaRemoteWidth = 640 - gachaVideoWidth = 360 - gachaDuration = 6 + gachaRemoteFPS = 24 + gachaRemoteWidth = 640 + gachaRemoteHeight = 360 +) + +// gachaStyle is one wish animation the renderer serves: its path next to +// /api/gif, the clip length in seconds, and the frame size it renders for a +// gachaRemoteWidth request. +type gachaStyle struct { + Path string + Duration int + Width int + Height int +} + +var ( + // The card-pack wish is portrait: the requested width is its long edge. + gachaStyleCardPack = gachaStyle{Path: "gacha", Duration: 6, Width: gachaRemoteHeight, Height: gachaRemoteWidth} + gachaStyleGenshin = gachaStyle{Path: "genshin", Duration: 7, Width: gachaRemoteWidth, Height: gachaRemoteHeight} ) type gachaAPIRequest struct { @@ -25,10 +38,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/genshin 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 @@ -36,12 +50,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 } @@ -68,15 +83,15 @@ 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: gachaDuration, - Width: gachaVideoWidth, - Height: gachaRemoteWidth, + Duration: style.Duration, + Width: style.Width, + Height: style.Height, }, nil } diff --git a/internal/modules/random/gacha_api_client_test.go b/internal/modules/random/gacha_api_client_test.go index 7114c18..90a3f40 100644 --- a/internal/modules/random/gacha_api_client_test.go +++ b/internal/modules/random/gacha_api_client_test.go @@ -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 7f30ff9..8577060 100644 --- a/internal/modules/random/gacha_command.go +++ b/internal/modules/random/gacha_command.go @@ -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" + genshinUsage = "Usage: /genshin \nOptions are 5* by default; prefix 4* or 3* to lower one, e.g. /genshin Pizza, 4* Pho, 3* Rice" gachaPlaceholder = "Wishing..." gachaFilename = "gacha.mp4" @@ -58,13 +59,27 @@ func gachaResultText(option gachaOption) string { return strings.Repeat("★", option.Rarity) + " " + option.Label } -// gachaCommand picks one option and renders it as a wish animation, falling -// back to a text reply when rendering fails. func gachaCommand() modules.Command { + return newGachaCommand("gacha", modules.VisibilityPublic, + "Wish for one comma-separated option as a gacha card pack; 5* by default, prefix 4* or 3*", + gachaUsage, gachaStyleCardPack) +} + +// genshinCommand wishes with the Genshin-style meteor animation. It is +// unlisted: anyone can run it, but it stays out of the command menu and /help. +func genshinCommand() modules.Command { + return newGachaCommand("genshin", modules.VisibilityUnlisted, + "Wish for one comma-separated option, Genshin style; 5* by default, prefix 4* or 3*", + genshinUsage, gachaStyleGenshin) +} + +// 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 as a gacha card pack; 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 { @@ -72,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. @@ -83,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]) } @@ -104,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 6fb49f0..524ceea 100644 --- a/internal/modules/random/gacha_command_test.go +++ b/internal/modules/random/gacha_command_test.go @@ -173,3 +173,53 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) { t.Fatalf("sendAnimation calls = %d, want 1", animations) } } + +func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(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, "/genshin Bún bò")) + + if gotPath != "/api/genshin" { + t.Fatalf("path = %q, want /api/genshin", 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()) + } + for field, want := range map[string]string{"duration": "7", "width": "640", "height": "360"} { + if got := animation.Form[field]; got != want { + t.Fatalf("%s = %q, want %s", field, got, want) + } + } +} + +func TestGenshin_EmptyArgsRepliesGenshinUsage(t *testing.T) { + rb := installRandom(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/genshin")) + + if got := rb.LastSent().Text(); got != genshinUsage { + t.Errorf("genshin reply = %q, want usage %q", got, genshinUsage) + } +} diff --git a/internal/modules/random/random.go b/internal/modules/random/random.go index 15f2368..8b9df80 100644 --- a/internal/modules/random/random.go +++ b/internal/modules/random/random.go @@ -1,7 +1,8 @@ // Package random groups the commands that pick one option at random: /random // (plain text pick), /wheelofnames (wheel GIF when a renderer is configured), -// and /gacha (card-pack wish MP4). The animated commands share the optional -// wheelofnames renderer and fall back to a text reply without it. +// /gacha (card-pack wish MP4), and the unlisted /genshin (Genshin-style meteor +// wish MP4). 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" @@ -13,6 +14,7 @@ func New(_ modules.Deps) modules.Module { randomCommand(), wheelOfNamesCommand(), gachaCommand(), + genshinCommand(), }, } } diff --git a/internal/modules/random/random_test.go b/internal/modules/random/random_test.go index 74dc15e..9a4a5bb 100644 --- a/internal/modules/random/random_test.go +++ b/internal/modules/random/random_test.go @@ -14,6 +14,7 @@ func TestNew_RegistersRandomGroup(t *testing.T) { "random": modules.VisibilityPublic, "wheelofnames": modules.VisibilityPublic, "gacha": modules.VisibilityPublic, + "genshin": modules.VisibilityUnlisted, } if len(mod.Commands) != len(want) { t.Fatalf("commands count = %d, want %d", len(mod.Commands), len(want))