diff --git a/README.md b/README.md index 75c6c02..614a3da 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Atlas via long polling and an in-process cron scheduler. | Module | What it does | |---|---| | `util` | `/help`, `/info`, `/stickerid` | -| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | +| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/wheelofnamesbeta`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | | `lol` | Pro-match schedule + daily push | diff --git a/go.mod b/go.mod index 4252725..d29173a 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-telegram/bot v1.20.0 github.com/robfig/cron/v3 v3.0.1 go.mongodb.org/mongo-driver/v2 v2.7.0 + golang.org/x/image v0.41.0 ) require ( diff --git a/go.sum b/go.sum index 8b4abc8..f381f70 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 7bc9dec..17df0a7 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -1,7 +1,9 @@ package misc import ( + "bytes" "context" + "image/gif" "strings" "testing" "time" @@ -226,6 +228,86 @@ func TestWheelOfNames_GroupSkipsDraftsButSendsFinal(t *testing.T) { } } +func TestWheelOfNamesBeta_UsageWhenMissingOptions(t *testing.T) { + for _, text := range []string{"/wheelofnamesbeta", "/wheelofnamesbeta , ,"} { + t.Run(text, func(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + + if got := rb.LastSent().Text(); got != wheelOfNamesBetaUsage { + t.Errorf("wheelofnamesbeta reply = %q, want usage %q", got, wheelOfNamesBetaUsage) + } + }) + } +} + +func TestWheelOfNamesBeta_RenderGIFTiming(t *testing.T) { + data, err := renderWheelOfNamesBetaGIF([]string{"Alice", "Bob"}, 0) + if err != nil { + t.Fatalf("renderWheelOfNamesBetaGIF: %v", err) + } + decoded, err := gif.DecodeAll(bytes.NewReader(data)) + if err != nil { + t.Fatalf("DecodeAll: %v", err) + } + if len(decoded.Image) != wheelBetaSpinFrames+1 { + t.Fatalf("frames = %d, want %d", len(decoded.Image), wheelBetaSpinFrames+1) + } + totalDelay := 0 + for i, delay := range decoded.Delay { + totalDelay += delay + if i < wheelBetaSpinFrames && delay != wheelBetaSpinDelay { + t.Fatalf("spin delay[%d] = %d, want %d", i, delay, wheelBetaSpinDelay) + } + } + if got := decoded.Delay[len(decoded.Delay)-1]; got != wheelBetaHoldDelay { + t.Fatalf("hold delay = %d, want %d", got, wheelBetaHoldDelay) + } + if totalDelay != wheelBetaDuration*100 { + t.Fatalf("total delay = %dcs, want %dcs", totalDelay, wheelBetaDuration*100) + } + if decoded.LoopCount != -1 { + t.Fatalf("loop count = %d, want -1", decoded.LoopCount) + } +} + +func TestWheelOfNamesBeta_SendsAnimationWithWinnerCaption(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice")) + + call := rb.LastSent() + if call.Method != "sendAnimation" { + t.Fatalf("method = %q, want sendAnimation", call.Method) + } + if got := call.Form["caption"]; got != "Winner: Alice" { + t.Fatalf("caption = %q, want Winner: Alice", got) + } + if got := call.Form["duration"]; got != "7" { + t.Fatalf("duration = %q, want 7", got) + } + if got := call.Form["width"]; got != "320" { + t.Fatalf("width = %q, want 320", got) + } + if got := call.Form["height"]; got != "320" { + t.Fatalf("height = %q, want 320", got) + } +} + +func TestWheelOfNamesBeta_ForwardsMessageThreadID(t *testing.T) { + rb, _ := installMisc(t, 999) + update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnamesbeta Alice") + update.Message.MessageThreadID = 42 + rb.Bot.ProcessUpdate(context.Background(), update) + + call := rb.LastSent() + if call.Method != "sendAnimation" { + t.Fatalf("method = %q, want sendAnimation", call.Method) + } + if got := call.Form["message_thread_id"]; got != "42" { + t.Fatalf("message_thread_id = %q, want 42", got) + } +} + // trongTruongHopUpdate is the inline counterpart of testutil.NewPrivateMessage // for cases that need control over From (username, names). The dispatcher // requires a bot_command entity, so we lift that from the helper API by reusing diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index 955a6aa..569fedb 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -1,7 +1,8 @@ // Package misc is a small stub module that proves the framework end-to-end: // /ping (public, exercises KV write), /ping_stats (protected, exercises KV // read), /random (public random picker), /wheelofnames (public streaming -// random picker), /the_answer (private easter egg). +// random picker), /wheelofnamesbeta (public GIF wheel picker), /the_answer +// (private easter egg). package misc import ( @@ -49,6 +50,7 @@ func New(deps modules.Deps) modules.Module { pingStatsCommand(store), randomCommand(), wheelOfNamesCommand(), + wheelOfNamesBetaCommand(), theAnswerCommand(), trongTruongHopCommand("trongtruonghop"), trongTruongHopCommand("tth"), diff --git a/internal/modules/misc/misc_test.go b/internal/modules/misc/misc_test.go index b5e0405..6af94bd 100644 --- a/internal/modules/misc/misc_test.go +++ b/internal/modules/misc/misc_test.go @@ -21,13 +21,14 @@ func TestNew_RegistersExpectedCommands(t *testing.T) { mod := New(deps) want := map[string]modules.Visibility{ - "ping": modules.VisibilityPublic, - "ping_stats": modules.VisibilityProtected, - "random": modules.VisibilityPublic, - "wheelofnames": modules.VisibilityPublic, - "the_answer": modules.VisibilityPrivate, - "trongtruonghop": modules.VisibilityPublic, - "tth": modules.VisibilityPublic, + "ping": modules.VisibilityPublic, + "ping_stats": modules.VisibilityProtected, + "random": modules.VisibilityPublic, + "wheelofnames": modules.VisibilityPublic, + "wheelofnamesbeta": modules.VisibilityPublic, + "the_answer": modules.VisibilityPrivate, + "trongtruonghop": modules.VisibilityPublic, + "tth": modules.VisibilityPublic, } if len(mod.Commands) != len(want) { t.Fatalf("commands count = %d, want %d", len(mod.Commands), len(want)) diff --git a/internal/modules/misc/wheelofnames_beta.go b/internal/modules/misc/wheelofnames_beta.go new file mode 100644 index 0000000..d2fac80 --- /dev/null +++ b/internal/modules/misc/wheelofnames_beta.go @@ -0,0 +1,184 @@ +package misc + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/draw" + "image/gif" + "math" + "strings" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +const ( + wheelBetaSize = 320 + wheelBetaRadius = 118 + wheelBetaSpinFrames = 30 + wheelBetaSpinDelay = 20 + wheelBetaHoldDelay = 100 + wheelBetaDuration = 7 +) + +var wheelBetaPalette = color.Palette{ + color.RGBA{R: 250, G: 251, B: 252, A: 255}, + color.RGBA{R: 30, G: 35, B: 42, A: 255}, + color.RGBA{R: 255, G: 255, B: 255, A: 255}, + color.RGBA{R: 221, G: 75, B: 75, A: 255}, + color.RGBA{R: 245, G: 180, B: 64, A: 255}, + color.RGBA{R: 76, G: 167, B: 120, A: 255}, + color.RGBA{R: 78, G: 135, B: 206, A: 255}, + color.RGBA{R: 147, G: 103, B: 196, A: 255}, + color.RGBA{R: 52, G: 197, B: 197, A: 255}, + color.RGBA{R: 235, G: 117, B: 164, A: 255}, +} + +func renderWheelOfNamesBetaGIF(options []string, winner int) ([]byte, error) { + if len(options) == 0 { + return nil, fmt.Errorf("no options") + } + if winner < 0 || winner >= len(options) { + return nil, fmt.Errorf("winner index %d out of range %d", winner, len(options)) + } + + frames := make([]*image.Paletted, 0, wheelBetaSpinFrames+1) + delays := make([]int, 0, wheelBetaSpinFrames+1) + finalRotation := finalWheelRotation(len(options), winner) + startRotation := finalRotation - 8*2*math.Pi + for i := 0; i < wheelBetaSpinFrames; i++ { + t := float64(i) / float64(wheelBetaSpinFrames-1) + progress := 1 - math.Pow(1-t, 3) + rotation := startRotation + (finalRotation-startRotation)*progress + frames = append(frames, renderWheelBetaFrame(options, winner, rotation, false)) + delays = append(delays, wheelBetaSpinDelay) + } + frames = append(frames, renderWheelBetaFrame(options, winner, finalRotation, true)) + delays = append(delays, wheelBetaHoldDelay) + + var buf bytes.Buffer + err := gif.EncodeAll(&buf, &gif.GIF{ + Image: frames, + Delay: delays, + LoopCount: -1, + Config: image.Config{ + ColorModel: wheelBetaPalette, + Width: wheelBetaSize, + Height: wheelBetaSize, + }, + }) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func renderWheelBetaFrame(options []string, winner int, rotation float64, reveal bool) *image.Paletted { + rect := image.Rect(0, 0, wheelBetaSize, wheelBetaSize) + img := image.NewPaletted(rect, wheelBetaPalette) + draw.Draw(img, rect, image.NewUniform(wheelBetaPalette[0]), image.Point{}, draw.Src) + + cx, cy := wheelBetaSize/2, wheelBetaSize/2 + segment := 2 * math.Pi / float64(len(options)) + r2 := wheelBetaRadius * wheelBetaRadius + for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ { + for x := cx - wheelBetaRadius; x <= cx+wheelBetaRadius; x++ { + dx, dy := x-cx, y-cy + if dx*dx+dy*dy > r2 { + continue + } + theta := normalizeAngle(math.Atan2(float64(dy), float64(dx)) - rotation) + idx := int(theta / segment) + colorIndex := byte(3 + idx%7) + img.SetColorIndex(x, y, colorIndex) + } + } + + drawCircle(img, cx, cy, 24, 2) + drawCircle(img, cx, cy, 18, 1) + drawPointer(img, cx, cy-wheelBetaRadius-10) + drawCenteredText(img, "WHEELOFNAMES BETA", cy+wheelBetaRadius+34, 1) + if reveal { + drawResultBand(img, asciiWheelText(options[winner], 28)) + } + return img +} + +func finalWheelRotation(optionCount, winner int) float64 { + segment := 2 * math.Pi / float64(optionCount) + return -math.Pi/2 - (float64(winner)+0.5)*segment +} + +func normalizeAngle(theta float64) float64 { + theta = math.Mod(theta, 2*math.Pi) + if theta < 0 { + theta += 2 * math.Pi + } + return theta +} + +func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) { + r2 := radius * radius + for y := cy - radius; y <= cy+radius; y++ { + for x := cx - radius; x <= cx+radius; x++ { + dx, dy := x-cx, y-cy + if dx*dx+dy*dy <= r2 { + img.SetColorIndex(x, y, colorIndex) + } + } + } +} + +func drawPointer(img *image.Paletted, cx, tipY int) { + for y := 0; y < 34; y++ { + half := y / 2 + for x := cx - half; x <= cx+half; x++ { + img.SetColorIndex(x, tipY+y, 1) + } + } +} + +func drawResultBand(img *image.Paletted, winner string) { + for y := 230; y < 282; y++ { + for x := 28; x < wheelBetaSize-28; x++ { + img.SetColorIndex(x, y, 2) + } + } + drawCenteredText(img, "WINNER", 250, 1) + drawCenteredText(img, winner, 270, 1) +} + +func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) { + face := basicfont.Face7x13 + width := font.MeasureString(face, text).Ceil() + x := (wheelBetaSize - width) / 2 + drawer := font.Drawer{ + Dst: img, + Src: image.NewUniform(wheelBetaPalette[colorIndex]), + Face: face, + Dot: fixed.P(x, baselineY), + } + drawer.DrawString(text) +} + +func asciiWheelText(s string, limit int) string { + var b strings.Builder + for _, r := range s { + if b.Len() >= limit { + break + } + if r < 32 || r > 126 { + b.WriteByte('?') + continue + } + b.WriteRune(r) + } + out := strings.TrimSpace(b.String()) + if out == "" { + return "winner" + } + return out +} diff --git a/internal/modules/misc/wheelofnames_beta_command.go b/internal/modules/misc/wheelofnames_beta_command.go new file mode 100644 index 0000000..9cdbf02 --- /dev/null +++ b/internal/modules/misc/wheelofnames_beta_command.go @@ -0,0 +1,58 @@ +package misc + +import ( + "bytes" + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + wheelOfNamesBetaUsage = "Usage: /wheelofnamesbeta , , ..." + wheelBetaFilename = "wheelofnamesbeta.gif" +) + +func wheelOfNamesBetaCommand() modules.Command { + return modules.Command{ + Name: "wheelofnamesbeta", + Visibility: modules.VisibilityPublic, + Description: "Spin an animated wheel GIF for comma-separated options", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + options := splitWheelOptions(chathelper.ArgAfterCommand(update.Message.Text)) + if len(options) == 0 { + return chathelper.Reply(ctx, b, update.Message, wheelOfNamesBetaUsage) + } + winner := pickWheelOption(options) + data, err := renderWheelOfNamesBetaGIF(options, winner) + if err != nil { + log.Error("wheelofnamesbeta render failed", "err", err) + return chathelper.Reply(ctx, b, update.Message, options[winner]) + } + _, err = b.SendAnimation(ctx, &bot.SendAnimationParams{ + ChatID: update.Message.Chat.ID, + MessageThreadID: update.Message.MessageThreadID, + Animation: &models.InputFileUpload{ + Filename: wheelBetaFilename, + Data: bytes.NewReader(data), + }, + Duration: wheelBetaDuration, + Width: wheelBetaSize, + Height: wheelBetaSize, + Caption: "Winner: " + options[winner], + }) + if err != nil { + log.Warn("wheelofnamesbeta send animation failed", "chat", update.Message.Chat.ID, "err", err) + return chathelper.Reply(ctx, b, update.Message, options[winner]) + } + return nil + }, + } +} diff --git a/internal/testutil/recording_bot.go b/internal/testutil/recording_bot.go index 8cc02e4..2b3cd05 100644 --- a/internal/testutil/recording_bot.go +++ b/internal/testutil/recording_bot.go @@ -131,7 +131,7 @@ func apiMethodFromPath(p string) string { // expect a Message; most others accept a bool. func okResponseFor(method string) string { switch method { - case "sendMessage", "sendSticker", "sendPhoto", "sendDocument", "sendVideo": + case "sendMessage", "sendSticker", "sendPhoto", "sendDocument", "sendVideo", "sendAnimation": // Minimal shape: id, date, chat. Bot library decodes via json so // extra fields are ignored. msg := map[string]any{ diff --git a/plans/reports/260706-1134-wheelofnamesbeta-gif-research.md b/plans/reports/260706-1134-wheelofnamesbeta-gif-research.md new file mode 100644 index 0000000..8ce3beb --- /dev/null +++ b/plans/reports/260706-1134-wheelofnamesbeta-gif-research.md @@ -0,0 +1,151 @@ +--- +type: research +date: 2026-07-06T11:34:00Z +topic: wheelofnamesbeta gif generation and Telegram delivery +skills: + - ck:research + - ck:project-organization +--- + +# Research Report: Wheelofnamesbeta Gif Generation + +## Executive Summary + +Use Go stdlib `image/gif` for GIF encoding. It supports multi-frame GIFs via +`EncodeAll`, frame delay in 1/100s, and loop control. Add only +`golang.org/x/image/font/basicfont` for self-contained bitmap text if labels or +winner text must appear inside frames. Avoid ffmpeg/cgo/runtime binaries. + +Send the generated GIF with Telegram `sendAnimation`, not `sendPhoto`. +Telegram Bot API defines `sendAnimation` for GIF or silent H.264/MPEG-4 +animation files, max 50 MB at current docs. Generate in memory, upload with +`models.InputFileUpload`, and set caption to the winner. + +Recommended `/wheelofnamesbeta` behavior: parse same comma input as +`/wheelofnames`, choose winner once, render 6s spin plus 1s hold. Keep canvas +small, frame count low, and palette fixed to avoid CPU/file-size spikes. + +## Research Methodology + +- Sources consulted: 4 +- Date: 2026-07-06 +- Gemini: disabled by `.ck.json`; used WebSearch +- Key terms: Go animated GIF EncodeAll, golang.org/x/image font basicfont, + Telegram Bot API sendAnimation, GIF optimization frame count palette + +## Key Findings + +### 1. Technology Overview + +`image/gif` is in Go stdlib and implements GIF decoding/encoding. `EncodeAll` +writes multiple paletted images as one GIF. The `GIF` struct stores successive +frames, delays in 1/100s, and loop count. This maps directly to "6s spin + 1s +hold": e.g. 30 frames at 20/100s each, then 1 final frame at 100/100s. + +`golang.org/x/image/font` provides text drawing interfaces. `basicfont` provides +fixed-size font faces. `Face7x13` is self-contained, uses printable ASCII, and +needs no font files. + +Telegram Bot API `sendAnimation` sends GIF or silent H.264/MPEG-4 animation +files and returns a sent message. Current limit: 50 MB. It supports normal +`chat_id` and `message_thread_id`, so it works for groups/topics unlike +`sendMessageDraft`. + +### 2. Current State And Trends + +- GIF is simple but inefficient compared to MP4. For this bot, low-res generated + vector-ish frames are small enough. +- Go stdlib avoids external binary dependencies and is deploy-safe on Coolify. +- `x/image/basicfont` is enough for readable short text; no custom TTF asset. + +### 3. Best Practices + +- Render fixed small canvas, e.g. 320x320. +- Use fixed palette and `image.NewPaletted`. +- Keep frame count low: 30 spin frames + 1 hold frame. +- Delay: spin frames 20/100s = 6s total; final hold 100/100s = 1s. +- Set `LoopCount: -1` so the GIF plays once. Telegram clients may still expose + replay behavior, but file intent is one pass. +- Put exact winner in caption too, because GIF autoplay/download can vary by + client settings. + +### 4. Security Considerations + +- No user-provided image decoding; generated pixels only, so image bomb risk low. +- Bound input names and rendered text length to avoid huge memory/caption work. +- Never write temp files; generate in memory. +- Avoid shelling to ffmpeg from command handler. + +### 5. Performance Insights + +GIF frame memory is width * height * frame count. At 320x320 and 31 frames, raw +paletted data is about 3.2 MB before encoding plus overhead. Acceptable for one +long-polling handler. + +Drawing simple wedges/lines/text in Go is CPU-cheap. Avoid antialiasing and +large canvases. 20/100s frame delay keeps total frames modest. + +## Comparative Analysis + +| Option | Pros | Cons | Verdict | +|---|---|---|---| +| stdlib `image/gif` | No new heavy dependency, pure Go, direct timing control | Basic drawing only | Use | +| `x/image/font/basicfont` | Small official Go subrepo, no font files | ASCII-ish visual, small font | Use for labels | +| `gg` | Easier antialiased drawing/text | Adds third-party dependency | Reject for beta | +| ffmpeg MP4/GIF | Better compression/quality | External binary, shell/runtime complexity | Reject | + +## Implementation Recommendations + +### Quick Start Guide + +1. Add `/wheelofnamesbeta` command in `misc`. +2. Reuse `splitWheelOptions` and `pickWheelOption`. +3. Generate GIF bytes in memory. +4. Send using `b.SendAnimation` with `models.InputFileUpload`. +5. Caption final winner. +6. Add tests that assert `sendAnimation` and encoded GIF timing. + +### Code Sketch + +```go +buf := bytes.Buffer{} +err := gif.EncodeAll(&buf, &gif.GIF{ + Image: frames, + Delay: delays, // 30 x 20, then 100 + LoopCount: -1, +}) +_, err = b.SendAnimation(ctx, &bot.SendAnimationParams{ + ChatID: msg.Chat.ID, + MessageThreadID: msg.MessageThreadID, + Animation: &models.InputFileUpload{ + Filename: "wheelofnamesbeta.gif", + Data: bytes.NewReader(buf.Bytes()), + }, + Duration: 7, + Caption: "Winner: " + winner, +}) +``` + +### Common Pitfalls + +- `sendPhoto` can flatten GIF to a static image. Use `sendAnimation`. +- GIF delays are centiseconds, not milliseconds. +- Large canvases or many frames can bloat files fast. +- Telegram client autoplay is user-controlled; caption must contain winner. + +## Resources And References + +- Go `image/gif` docs: https://pkg.go.dev/image/gif +- Go `golang.org/x/image/font` docs: https://pkg.go.dev/golang.org/x/image/font +- Go `basicfont` docs: https://pkg.go.dev/golang.org/x/image/font/basicfont +- Telegram `sendAnimation`: https://core.telegram.org/bots/api#sendanimation + +## Next Steps + +1. Implement pure-Go GIF renderer in `internal/modules/misc`. +2. Add `/wheelofnamesbeta` command surfaces and tests. +3. Run focused misc tests, full `go test ./...`, and `go vet ./...`. + +## Unresolved Questions + +- None. diff --git a/telegram-commands.json b/telegram-commands.json index e5f914c..d97b540 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -16,6 +16,10 @@ "command": "wheelofnames", "description": "Spin a suspenseful wheel for comma-separated options" }, + { + "command": "wheelofnamesbeta", + "description": "Send an animated wheel GIF for comma-separated options" + }, { "command": "trongtruonghop", "description": "Phát biểu disclaimer cho thành viên hiện tại"