mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 12:28:54 +00:00
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:
1 parent
7ca1468a43
commit
fafe61b28b
8 files changed
+39
-126
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user