mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 04:16:43 +00:00
feat(misc): add wheelofnames beta animation
This commit is contained in:
1 parent
079d506695
commit
3763b293d6
11 files changed
+495
-10
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user