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.
This commit is contained in:
tiennm99 committed 2026-10-01 23:38:21 +07:00
1 parent 59f597016b
commit 53f387c576
8 files changed
+123 -36

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` (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) |
+4
View File
@@ -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).
+34 -19
View File
@@ -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
}
@@ -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")
}
})
+25 -10
View File
@@ -18,6 +18,7 @@ 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"
genshinUsage = "Usage: /genshin <option,...>\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: "<option,...>",
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
},
@@ -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)
}
}
+4 -2
View File
@@ -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(),
},
}
}
+1
View File
@@ -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))