feat(random)!: send /gacha as the portrait card-pack wish

wheelofnames now renders /api/gacha as the 6-second portrait card-pack
wish and no longer serves /api/gachabeta. /gacha sends it at 360x640, and
the unlisted /gachabeta command and the per-style renderer settings are
removed.

BREAKING CHANGE: /gachabeta is gone; /gacha needs a wheelofnames build that
renders the portrait wish.
This commit is contained in:
tiennm99 committed 2026-10-01 22:13:59 +07:00
1 parent 7ca1468a43
commit fafe61b28b
8 files changed
+39 -126

No files matched your search

+1 -1
View File
@@ -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) |
+2 -6
View File
@@ -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).
+19 -34
View File
@@ -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
}
@@ -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")
}
})
+10 -25
View File
@@ -18,7 +18,6 @@ import (
const (
gachaUsage = "Usage: /gacha <option,...>\nOptions are 5* by default; prefix 4* or 3* to lower one, e.g. /gacha Pizza, 4* Pho, 3* Rice"
gachaBetaUsage = "Usage: /gachabeta <option,...>\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: "<option,...>",
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
},
+1 -51
View File
@@ -100,7 +100,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
if want := `Result: <span class="tg-spoiler">★★★★ Pho</span>`; 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)
}
}
+2 -4
View File
@@ -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(),
},
}
}
-1
View File
@@ -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))