feat(misc): add wheelofnames beta animation

This commit is contained in:
tiennm99 committed 2026-07-06 11:41:20 +07:00
1 parent 079d506695
commit 3763b293d6
11 files changed
+495 -10

No files matched your search

+1 -1
View File
@@ -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 |
+1
View File
@@ -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 (
+2
View File
@@ -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=
+82
View File
@@ -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
+3 -1
View File
@@ -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"),
+8 -7
View File
@@ -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))
+184
View File
@@ -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
}
@@ -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 <option1>, <option2>, ..."
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
},
}
}
+1 -1
View File
@@ -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{
@@ -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.
+4
View File
@@ -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"