diff --git a/README.md b/README.md index 1d52fd6..221b768 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` (Genshin-style wish; options are 5★ by default, prefix `4*` or `3*`), `/gachabeta` (unlisted; beta version of `/gacha`). 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*`). 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 b810387..45af681 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -86,17 +86,13 @@ 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 +segment, so `.../api/gif` becomes `.../api/gacha`. It renders a 6-second +`360x640` portrait silent MP4 wish animation (a card pack torn open), posts `Wishing...` while it renders, and falls back to a text reply such as `★★★★★ Pizza` on the same failures. Every 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 beta version -of the wish from `.../api/gachabeta` on the same service as a `360x640` -portrait 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 4437abc..b20ef01 100644 --- a/internal/modules/random/gacha_api_client.go +++ b/internal/modules/random/gacha_api_client.go @@ -10,25 +10,12 @@ import ( ) const ( - 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 ( - gachaStyleWish = gachaStyle{Path: "gacha", Duration: 7, Width: gachaRemoteWidth, Height: gachaRemoteHeight} - // The beta wish is portrait: the requested width is its long edge. - gachaStyleBeta = gachaStyle{Path: "gachabeta", Duration: 6, Width: gachaRemoteHeight, Height: gachaRemoteWidth} + 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 ) type gachaAPIRequest struct { @@ -38,11 +25,10 @@ type gachaAPIRequest struct { Width int `json:"width"` } -// 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) { +// 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 @@ -50,13 +36,12 @@ func gachaAPIEndpoint(rawURL, path string) (*url.URL, error) { base := *endpoint base.Path = strings.TrimSuffix(base.Path, "/") base.RawPath = "" - return base.ResolveReference(&url.URL{Path: path}), nil + return base.ResolveReference(&url.URL{Path: "gacha"}), nil } -// 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) +// 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 } @@ -83,15 +68,15 @@ func isMP4(data []byte) bool { return len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp")) } -func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (wheelAnimation, error) { - data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, style.Path, label, rarity) +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: style.Duration, - Width: style.Width, - Height: style.Height, + Duration: gachaDuration, + Width: gachaVideoWidth, + Height: gachaRemoteWidth, }, nil } diff --git a/internal/modules/random/gacha_api_client_test.go b/internal/modules/random/gacha_api_client_test.go index 90a3f40..7114c18 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, "gacha") + got, err := gachaAPIEndpoint(raw) 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("", "gacha"); err != errWheelAPINotConfigured { + if _, err := gachaAPIEndpoint(""); 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(), "gacha", tc.label, tc.rarity); err == nil { + if _, err := client.RenderGacha(context.Background(), 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(), "gacha", "a", 3); err == nil { + if _, err := client.RenderGacha(context.Background(), "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 e7b8308..7f30ff9 100644 --- a/internal/modules/random/gacha_command.go +++ b/internal/modules/random/gacha_command.go @@ -18,7 +18,6 @@ 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,27 +58,13 @@ 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, Genshin style; 5* by default, prefix 4* or 3*", - gachaUsage, gachaStyleWish) -} - -// gachaBetaCommand trials the beta version of the wish animation. 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, - "Gacha (beta version); 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: name, - Visibility: visibility, - Description: description, + Name: "gacha", + Visibility: modules.VisibilityPublic, + Description: "Wish for one comma-separated option as a gacha card pack; 5* by default, prefix 4* or 3*", Parameters: "", Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { if update.Message == nil { @@ -87,7 +72,7 @@ func newGachaCommand(name string, visibility modules.Visibility, description, us } options := parseGachaOptions(chathelper.ArgAfterCommand(update.Message.Text)) if len(options) == 0 { - return chathelper.Reply(ctx, b, update.Message, usage) + return chathelper.Reply(ctx, b, update.Message, gachaUsage) } // Every option is equally likely; the rarity tag only styles the // wish animation and the result text. @@ -98,10 +83,10 @@ func newGachaCommand(name string, visibility modules.Visibility, description, us } placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder) - animation, err := renderGachaAnimation(ctx, style, options[winner].Label, options[winner].Rarity) + animation, err := renderGachaAnimation(ctx, options[winner].Label, options[winner].Rarity) if err != nil { if !errors.Is(err, errWheelAPINotConfigured) { - log.Warn("gacha remote render failed", "command", name, "err", err) + log.Warn("gacha remote render failed", "err", err) } return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) } @@ -119,11 +104,11 @@ func newGachaCommand(name string, visibility modules.Visibility, description, us ParseMode: models.ParseModeHTML, }) if err != nil { - log.Warn("gacha send animation failed", "command", name, "chat", update.Message.Chat.ID, "err", err) + 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", "command", name, "chat", update.Message.Chat.ID, "err", err) + log.Warn("gacha placeholder delete failed", "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 3b64e67..6fb49f0 100644 --- a/internal/modules/random/gacha_command_test.go +++ b/internal/modules/random/gacha_command_test.go @@ -100,7 +100,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) { 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"} { + for field, want := range map[string]string{"parse_mode": "HTML", "duration": "6", "width": "360", "height": "640"} { if got := call.Form[field]; got != want { t.Fatalf("%s = %q, want %q", field, got, want) } @@ -173,53 +173,3 @@ 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()) - } - for field, want := range map[string]string{"duration": "6", "width": "360", "height": "640"} { - if got := animation.Form[field]; got != want { - t.Fatalf("%s = %q, want %s", field, got, want) - } - } -} - -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/random.go b/internal/modules/random/random.go index 590b685..15f2368 100644 --- a/internal/modules/random/random.go +++ b/internal/modules/random/random.go @@ -1,8 +1,7 @@ // 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 (beta version -// of the gacha wish). The animated commands share the optional wheelofnames renderer -// and fall back to a text reply without it. +// and /gacha (card-pack 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" @@ -14,7 +13,6 @@ func New(_ modules.Deps) modules.Module { randomCommand(), wheelOfNamesCommand(), gachaCommand(), - gachaBetaCommand(), }, } } diff --git a/internal/modules/random/random_test.go b/internal/modules/random/random_test.go index 5c0c06e..74dc15e 100644 --- a/internal/modules/random/random_test.go +++ b/internal/modules/random/random_test.go @@ -14,7 +14,6 @@ func TestNew_RegistersRandomGroup(t *testing.T) { "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))