feat(misc): promote wheelofnames gif command

This commit is contained in:
2026-07-09 13:14:11 +07:00
parent 38d1db3054
commit 0bd7d71513
19 changed files with 764 additions and 810 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ WC_FOOTBALL_DATA_TOKEN=
# Commit in Build" disabled so Docker layer cache survives across commits.
# Local `docker compose up` has none, so deploynotify reports "unknown".
# Optional Remotion renderer for /wheelofnamesbeta. Leave blank to use the
# Optional Remotion renderer for /wheelofnames. Leave blank to use the
# built-in Go GIF renderer and fallback behavior.
WHEELOFNAMES_API_URL=
# Bearer token matching the wheelofnames service API_TOKEN when URL is set.
+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`, `/wheelofnamesbeta`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`) + daily push |
+4 -4
View File
@@ -34,7 +34,7 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
| `OWNER_ID` | optional | owner-only commands (renamed from `BOT_OWNER_ID`) |
| `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) |
| `WC_FOOTBALL_DATA_TOKEN` | optional | football-data.org token for the `wc` module |
| `WHEELOFNAMES_API_URL` | optional | full `/api/gif` endpoint for remote `/wheelofnamesbeta` GIF rendering |
| `WHEELOFNAMES_API_URL` | optional | full `/api/gif` endpoint for remote `/wheelofnames` GIF rendering |
| `WHEELOFNAMES_API_TOKEN` | optional | bearer token matching the wheelofnames service `API_TOKEN` |
**Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`,
@@ -46,7 +46,7 @@ overrides are not supported in runtime env; modules use coded defaults.
### Optional wheelofnames renderer
`/wheelofnamesbeta` uses the built-in Go GIF renderer when
`/wheelofnames` uses the built-in Go GIF renderer when
`WHEELOFNAMES_API_URL` is unset. If a self-hosted `wheelofnames` Remotion
service is available, set the URL to its full GIF endpoint and set the token to
the same value as the service `API_TOKEN`:
@@ -67,8 +67,8 @@ WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>
The bot sends outbound HTTP only; no public bot ingress is required. Remote
renders use `512px`, `20fps`, and `7` seconds total by default. If the remote
service is unset, unavailable, unauthorized, or returns a non-GIF response, the
bot falls back to the local renderer without revealing the winner in the GIF
caption.
bot falls back to the local renderer. The GIF caption includes the result behind
Telegram spoiler formatting.
## 1. MongoDB Atlas (M0)
+101 -179
View File
@@ -46,13 +46,6 @@ func installMisc(t *testing.T, ownerID int64) (*testutil.RecordingBot, storage.D
return rb, store
}
func withoutWheelDraftDelay(t *testing.T) {
t.Helper()
prev := wheelDraftFrameDelay
wheelDraftFrameDelay = 0
t.Cleanup(func() { wheelDraftFrameDelay = prev })
}
func TestPing_RepliesPongAndWritesStore(t *testing.T) {
rb, store := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping"))
@@ -161,139 +154,51 @@ func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) {
}
}
func TestWheelOfNames_SingleOption(t *testing.T) {
withoutWheelDraftDelay(t)
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
if got := rb.LastSent().Text(); got != "Alice" {
t.Errorf("wheelofnames reply = %q, want Alice", got)
}
}
func TestWheelOfNames_PicksFromTrimmedOptions(t *testing.T) {
withoutWheelDraftDelay(t)
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol"))
got := rb.LastSent().Text()
if got != "Alice" && got != "Bob" && got != "Carol" {
t.Errorf("wheelofnames reply = %q, want one of Alice/Bob/Carol", got)
}
}
func TestWheelOfNames_IgnoresEmptySegments(t *testing.T) {
withoutWheelDraftDelay(t)
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames , Alice , , Bob ,"))
got := rb.LastSent().Text()
if got != "Alice" && got != "Bob" {
t.Errorf("wheelofnames reply = %q, want Alice or Bob", got)
}
}
func TestWheelOfNames_StreamsDraftsBeforeFinalInPrivateChat(t *testing.T) {
withoutWheelDraftDelay(t)
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
calls := rb.Sent()
if len(calls) != 7 {
t.Fatalf("calls = %d, want 6 drafts + final sendMessage: %+v", len(calls), calls)
}
draftID := ""
for i := 0; i < 6; i++ {
if calls[i].Method != "sendMessageDraft" {
t.Fatalf("call %d method = %q, want sendMessageDraft", i, calls[i].Method)
}
if !strings.Contains(calls[i].Text(), "Alice") {
t.Errorf("draft %d text = %q, want to preview Alice", i, calls[i].Text())
}
if got := calls[i].Form["draft_id"]; got == "" || got == "0" {
t.Fatalf("draft %d id = %q, want non-zero", i, got)
} else if draftID == "" {
draftID = got
} else if got != draftID {
t.Fatalf("draft %d id = %q, want same draft id %q", i, got, draftID)
}
}
if calls[6].Method != "sendMessage" || calls[6].Text() != "Alice" {
t.Fatalf("final call = %+v, want sendMessage Alice", calls[6])
}
}
func TestWheelOfNames_GroupSkipsDraftsButSendsFinal(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100, 7, "/wheelofnames Alice"))
calls := rb.Sent()
if len(calls) != 1 {
t.Fatalf("calls = %d, want only final sendMessage in groups: %+v", len(calls), calls)
}
if calls[0].Method != "sendMessage" || calls[0].Text() != "Alice" {
t.Fatalf("group call = %+v, want sendMessage Alice", calls[0])
}
}
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)
func TestWheelOfNames_RenderGIFTiming(t *testing.T) {
data, err := renderWheelOfNamesGIF([]string{"Alice", "Bob"}, 0)
if err != nil {
t.Fatalf("renderWheelOfNamesBetaGIF: %v", err)
t.Fatalf("renderWheelOfNamesGIF: %v", err)
}
decoded, err := gif.DecodeAll(bytes.NewReader(data))
if err != nil {
t.Fatalf("DecodeAll: %v", err)
}
if len(decoded.Image) != wheelBetaSpinFrames+wheelBetaHoldFrames {
t.Fatalf("frames = %d, want %d", len(decoded.Image), wheelBetaSpinFrames+wheelBetaHoldFrames)
if len(decoded.Image) != wheelSpinFrames+wheelHoldFrames {
t.Fatalf("frames = %d, want %d", len(decoded.Image), wheelSpinFrames+wheelHoldFrames)
}
totalDelay := 0
spinDelay := 0
holdDelay := 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 i < wheelSpinFrames && delay != wheelSpinDelay {
t.Fatalf("spin delay[%d] = %d, want %d", i, delay, wheelSpinDelay)
}
if i < wheelBetaSpinFrames {
if i < wheelSpinFrames {
spinDelay += delay
continue
}
if delay != wheelBetaHoldDelay {
t.Fatalf("hold delay[%d] = %d, want %d", i, delay, wheelBetaHoldDelay)
if delay != wheelHoldDelay {
t.Fatalf("hold delay[%d] = %d, want %d", i, delay, wheelHoldDelay)
}
holdDelay += delay
}
if spinDelay != wheelBetaSpinDuration*100 {
t.Fatalf("spin delay total = %dcs, want %dcs", spinDelay, wheelBetaSpinDuration*100)
if spinDelay != wheelSpinDuration*100 {
t.Fatalf("spin delay total = %dcs, want %dcs", spinDelay, wheelSpinDuration*100)
}
if holdDelay != wheelBetaHoldDuration*100 {
t.Fatalf("hold delay total = %dcs, want %dcs", holdDelay, wheelBetaHoldDuration*100)
if holdDelay != wheelHoldDuration*100 {
t.Fatalf("hold delay total = %dcs, want %dcs", holdDelay, wheelHoldDuration*100)
}
if totalDelay != wheelBetaDuration*100 {
t.Fatalf("total delay = %dcs, want %dcs", totalDelay, wheelBetaDuration*100)
if totalDelay != wheelDuration*100 {
t.Fatalf("total delay = %dcs, want %dcs", totalDelay, wheelDuration*100)
}
if equalPalettedFrames(decoded.Image[wheelBetaSpinFrames-1], decoded.Image[wheelBetaSpinFrames]) {
if equalPalettedFrames(decoded.Image[wheelSpinFrames-1], decoded.Image[wheelSpinFrames]) {
t.Fatalf("first result frame matches last spin frame, want visible RESULT transition")
}
if equalPalettedFrames(decoded.Image[wheelBetaSpinFrames], decoded.Image[wheelBetaSpinFrames+1]) {
if equalPalettedFrames(decoded.Image[wheelSpinFrames], decoded.Image[wheelSpinFrames+1]) {
t.Fatalf("first celebration frame matches second celebration frame, want visible result burst")
}
firstStableHoldFrame := wheelBetaSpinFrames + wheelBetaCelebrateFrames
firstStableHoldFrame := wheelSpinFrames + wheelCelebrateFrames
for i := firstStableHoldFrame + 1; i < len(decoded.Image); i++ {
if !equalPalettedFrames(decoded.Image[firstStableHoldFrame], decoded.Image[i]) {
t.Fatalf("stable result hold frame %d differs from frame %d", i, firstStableHoldFrame)
@@ -311,22 +216,22 @@ func equalPalettedFrames(a, b *image.Paletted) bool {
return bytes.Equal(a.Pix, b.Pix)
}
func TestWheelOfNamesBeta_CurrentOptionTracksPointer(t *testing.T) {
func TestWheelOfNames_CurrentOptionTracksPointer(t *testing.T) {
for winner := range []string{"Alice", "Bob", "Carol", "Dana"} {
rotation := finalWheelRotation(4, winner)
if got := currentWheelBetaIndex(4, rotation); got != winner {
t.Fatalf("currentWheelBetaIndex at final rotation = %d, want %d", got, winner)
if got := currentWheelIndex(4, rotation); got != winner {
t.Fatalf("currentWheelIndex at final rotation = %d, want %d", got, winner)
}
}
}
func TestWheelOfNamesBeta_RandomSpinProfileKeepsWinnerUnderPointer(t *testing.T) {
func TestWheelOfNames_RandomSpinProfileKeepsWinnerUnderPointer(t *testing.T) {
rng := rand.New(rand.NewPCG(1, 2))
for optionCount := 2; optionCount <= 10; optionCount++ {
for winner := 0; winner < optionCount; winner++ {
for spin := 0; spin < 20; spin++ {
profile := newWheelBetaSpinProfile(optionCount, winner, rng)
if got := currentWheelBetaIndex(optionCount, profile.finalRotation); got != winner {
profile := newWheelSpinProfile(optionCount, winner, rng)
if got := currentWheelIndex(optionCount, profile.finalRotation); got != winner {
t.Fatalf("optionCount=%d winner=%d spin=%d final index = %d", optionCount, winner, spin, got)
}
if got := profile.rotationAt(1); math.Abs(got-profile.finalRotation) > 1e-9 {
@@ -337,10 +242,10 @@ func TestWheelOfNamesBeta_RandomSpinProfileKeepsWinnerUnderPointer(t *testing.T)
}
}
func TestWheelOfNamesBeta_SpinProfileVariesBetweenSpins(t *testing.T) {
func TestWheelOfNames_SpinProfileVariesBetweenSpins(t *testing.T) {
rng := rand.New(rand.NewPCG(10, 20))
first := newWheelBetaSpinProfile(5, 2, rng)
second := newWheelBetaSpinProfile(5, 2, rng)
first := newWheelSpinProfile(5, 2, rng)
second := newWheelSpinProfile(5, 2, rng)
if first.startRotation == second.startRotation &&
first.finalRotation == second.finalRotation &&
first.accelEnd == second.accelEnd &&
@@ -350,9 +255,9 @@ func TestWheelOfNamesBeta_SpinProfileVariesBetweenSpins(t *testing.T) {
}
}
func TestWheelOfNamesBeta_SpinProfileProgressIsMonotonic(t *testing.T) {
func TestWheelOfNames_SpinProfileProgressIsMonotonic(t *testing.T) {
rng := rand.New(rand.NewPCG(30, 40))
profile := newWheelBetaSpinProfile(6, 4, rng)
profile := newWheelSpinProfile(6, 4, rng)
prev := -1.0
for i := 0; i <= 100; i++ {
progress := profile.progressAt(float64(i) / 100)
@@ -363,19 +268,19 @@ func TestWheelOfNamesBeta_SpinProfileProgressIsMonotonic(t *testing.T) {
}
}
func TestWheelOfNamesBeta_PointerPointsIntoWheel(t *testing.T) {
img := renderWheelBetaFrame([]string{"Alice", "Bob"}, 0, finalWheelRotation(2, 0), false)
cx := wheelBetaSize / 2
cy := wheelBetaSize / 2
tipX := cx + wheelBetaRadius
func TestWheelOfNames_PointerPointsIntoWheel(t *testing.T) {
img := renderWheelFrame([]string{"Alice", "Bob"}, 0, finalWheelRotation(2, 0), false)
cx := wheelSize / 2
cy := wheelSize / 2
tipX := cx + wheelRadius
if got := img.ColorIndexAt(tipX, cy); got != 1 {
t.Fatalf("pointer tip color = %d, want 1", got)
}
if got := img.ColorIndexAt(tipX+10, cy+5); got != 1 {
t.Fatalf("pointer shoulder color = %d, want 1", got)
}
if got := img.ColorIndexAt(tipX+12, cy); got != wheelBetaSliceColorIndexes[0] {
t.Fatalf("pointer body color = %d, want current slice color %d", got, wheelBetaSliceColorIndexes[0])
if got := img.ColorIndexAt(tipX+12, cy); got != wheelSliceColorIndexes[0] {
t.Fatalf("pointer body color = %d, want current slice color %d", got, wheelSliceColorIndexes[0])
}
if got := img.ColorIndexAt(tipX, cy+5); got == 1 {
t.Fatalf("pointer tip is too tall at color index %d", got)
@@ -385,43 +290,43 @@ func TestWheelOfNamesBeta_PointerPointsIntoWheel(t *testing.T) {
}
}
func TestWheelOfNamesBeta_FinalSliceRendersAtRightPointer(t *testing.T) {
func TestWheelOfNames_FinalSliceRendersAtRightPointer(t *testing.T) {
options := []string{"Alice", "Bob", "Carol", "Dana"}
winner := 2
img := renderWheelBetaFrame(options, winner, finalWheelRotation(len(options), winner), true)
x := wheelBetaSize/2 + wheelBetaRadius - 20
y := wheelBetaSize / 2
want := wheelBetaSliceColorIndexes[winner%len(wheelBetaSliceColorIndexes)]
img := renderWheelFrame(options, winner, finalWheelRotation(len(options), winner), true)
x := wheelSize/2 + wheelRadius - 20
y := wheelSize / 2
want := wheelSliceColorIndexes[winner%len(wheelSliceColorIndexes)]
if got := img.ColorIndexAt(x, y); got != want {
t.Fatalf("right pointer slice color = %d, want winner slice color %d", got, want)
}
}
func TestWheelOfNamesBeta_DrawsOptionLabelsInsideSlices(t *testing.T) {
func TestWheelOfNames_DrawsOptionLabelsInsideSlices(t *testing.T) {
options := []string{"Student", "Teacher", "Parent", "Staff"}
rotation := 0.0
img := renderWheelBetaFrame(options, 0, rotation, false)
img := renderWheelFrame(options, 0, rotation, false)
segment := 2 * math.Pi / float64(len(options))
angle := rotation + segment/2
centerX := wheelBetaSize/2 + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius)))
centerY := wheelBetaSize/2 + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius)))
centerX := wheelSize/2 + int(math.Round(math.Cos(angle)*float64(wheelSliceLabelRadius)))
centerY := wheelSize/2 + int(math.Round(math.Sin(angle)*float64(wheelSliceLabelRadius)))
bounds := image.Rect(centerX-28, centerY-28, centerX+28, centerY+28)
if got := countColorIndex(img, bounds, 1); got == 0 {
t.Fatalf("slice label dark pixels = %d, want > 0", got)
}
}
func TestWheelOfNamesBeta_RotatesOptionLabelsWithSlices(t *testing.T) {
func TestWheelOfNames_RotatesOptionLabelsWithSlices(t *testing.T) {
options := []string{"Rotate", "Teacher", "Parent", "Staff"}
rotation := -3 * math.Pi / 4
img := renderWheelBetaFrame(options, 0, rotation, false)
img := renderWheelFrame(options, 0, rotation, false)
segment := 2 * math.Pi / float64(len(options))
angle := rotation + segment/2
centerX := wheelBetaSize/2 + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius)))
centerY := wheelBetaSize/2 + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius)))
centerX := wheelSize/2 + int(math.Round(math.Cos(angle)*float64(wheelSliceLabelRadius)))
centerY := wheelSize/2 + int(math.Round(math.Sin(angle)*float64(wheelSliceLabelRadius)))
searchBounds := image.Rect(centerX-28, centerY-32, centerX+28, centerY+32)
labelBounds, ok := colorIndexBounds(img, searchBounds, wheelBetaInkColorIndex)
labelBounds, ok := colorIndexBounds(img, searchBounds, wheelInkColorIndex)
if !ok {
t.Fatalf("slice label dark pixels missing in %v", searchBounds)
}
@@ -430,22 +335,22 @@ func TestWheelOfNamesBeta_RotatesOptionLabelsWithSlices(t *testing.T) {
}
}
func TestWheelOfNamesBeta_DisplayTextNormalizesVietnamese(t *testing.T) {
func TestWheelOfNames_DisplayTextNormalizesVietnamese(t *testing.T) {
input := "không dấu Tiếng Việt Đặng Ơ Ư ấ ệ"
want := "khong dau Tieng Viet Dang O U a e"
got := wheelBetaDisplayText(input, 64)
got := wheelDisplayText(input, 64)
if got != want {
t.Fatalf("wheelBetaDisplayText() = %q, want %q", got, want)
t.Fatalf("wheelDisplayText() = %q, want %q", got, want)
}
if strings.Contains(got, "?") {
t.Fatalf("wheelBetaDisplayText() replaced Vietnamese with ?: %q", got)
t.Fatalf("wheelDisplayText() replaced Vietnamese with ?: %q", got)
}
}
func TestWheelOfNamesBeta_DisplayTextNormalizesDecomposedVietnamese(t *testing.T) {
got := wheelBetaDisplayText("tie\u0302\u0301ng Vie\u0323t", 32)
func TestWheelOfNames_DisplayTextNormalizesDecomposedVietnamese(t *testing.T) {
got := wheelDisplayText("tie\u0302\u0301ng Vie\u0323t", 32)
if got != "tieng Viet" {
t.Fatalf("wheelBetaDisplayText() = %q, want %q", got, "tieng Viet")
t.Fatalf("wheelDisplayText() = %q, want %q", got, "tieng Viet")
}
}
@@ -493,19 +398,19 @@ func colorIndexBounds(img *image.Paletted, bounds image.Rectangle, colorIndex by
return image.Rect(minX, minY, maxX, maxY), true
}
func TestWheelOfNamesBeta_SendsAnimationWithoutSpoilingCaption(t *testing.T) {
func TestWheelOfNames_SendsAnimationWithSpoilerCaption(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
call := rb.LastSent()
if call.Method != "sendAnimation" {
t.Fatalf("method = %q, want sendAnimation", call.Method)
}
if got := call.Form["caption"]; got != "Spinning..." {
t.Fatalf("caption = %q, want Spinning...", got)
if got := call.Form["caption"]; got != `Result: <span class="tg-spoiler">Alice</span>` {
t.Fatalf("caption = %q, want result spoiler", got)
}
if strings.Contains(call.Form["caption"], "Alice") {
t.Fatalf("caption spoils winner: %q", call.Form["caption"])
if got := call.Form["parse_mode"]; got != "HTML" {
t.Fatalf("parse_mode = %q, want HTML", got)
}
if got := call.Form["duration"]; got != "10" {
t.Fatalf("duration = %q, want 10", got)
@@ -518,8 +423,24 @@ func TestWheelOfNamesBeta_SendsAnimationWithoutSpoilingCaption(t *testing.T) {
}
}
func TestWheelOfNamesBeta_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got wheelBetaAPIRequest
func TestWheelOfNames_ResultCaptionEscapesHTML(t *testing.T) {
got := wheelResultCaption(`<Alice & Bob>`)
want := `Result: <span class="tg-spoiler">&lt;Alice &amp; Bob&gt;</span>`
if got != want {
t.Fatalf("wheelResultCaption() = %q, want %q", got, want)
}
}
func TestWheelOfNames_ResultCaptionTruncatesLongResult(t *testing.T) {
got := wheelResultCaption(strings.Repeat("a", wheelResultCaptionMaxRunes+1))
want := `Result: <span class="tg-spoiler">` + strings.Repeat("a", wheelResultCaptionMaxRunes) + `...</span>`
if got != want {
t.Fatalf("wheelResultCaption() length = %d, want truncated caption length %d", len(got), len(want))
}
}
func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got wheelAPIRequest
var gotAuthorization string
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -535,11 +456,11 @@ func TestWheelOfNamesBeta_UsesRemoteAPIWhenConfigured(t *testing.T) {
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesBetaAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesBetaAPITokenEnv, "remote-token")
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesAPITokenEnv, "remote-token")
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice, Bob, Carol"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol"))
if calls != 1 {
t.Fatalf("remote calls = %d, want 1", calls)
@@ -553,17 +474,18 @@ func TestWheelOfNamesBeta_UsesRemoteAPIWhenConfigured(t *testing.T) {
if got.WinnerIndex < 0 || got.WinnerIndex >= len(got.Options) {
t.Fatalf("winnerIndex = %d, want in range", got.WinnerIndex)
}
assertWheelBetaRemoteDefaults(t, got)
assertWheelRemoteDefaults(t, got)
call := rb.LastSent()
if call.Method != "sendAnimation" {
t.Fatalf("method = %q, want sendAnimation", call.Method)
}
if got := call.Form["caption"]; got != "Spinning..." {
t.Fatalf("caption = %q, want Spinning...", got)
wantCaption := wheelResultCaption(got.Options[got.WinnerIndex])
if got := call.Form["caption"]; got != wantCaption {
t.Fatalf("caption = %q, want %q", got, wantCaption)
}
if strings.Contains(call.Form["caption"], got.Options[got.WinnerIndex]) {
t.Fatalf("caption spoils winner: %q", call.Form["caption"])
if got := call.Form["parse_mode"]; got != "HTML" {
t.Fatalf("parse_mode = %q, want HTML", got)
}
if got := call.Form["duration"]; got != "7" {
t.Fatalf("duration = %q, want 7", got)
@@ -576,18 +498,18 @@ func TestWheelOfNamesBeta_UsesRemoteAPIWhenConfigured(t *testing.T) {
}
}
func TestWheelOfNamesBeta_RemoteFailureFallsBackToLocalAnimation(t *testing.T) {
func TestWheelOfNames_RemoteFailureFallsBackToLocalAnimation(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
http.Error(w, "no", http.StatusInternalServerError)
}))
defer server.Close()
t.Setenv(wheelOfNamesBetaAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesBetaAPITokenEnv, "remote-token")
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesAPITokenEnv, "remote-token")
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
if calls != 1 {
t.Fatalf("remote calls = %d, want 1", calls)
@@ -596,11 +518,11 @@ func TestWheelOfNamesBeta_RemoteFailureFallsBackToLocalAnimation(t *testing.T) {
if call.Method != "sendAnimation" {
t.Fatalf("method = %q, want sendAnimation", call.Method)
}
if got := call.Form["caption"]; got != "Spinning..." {
t.Fatalf("caption = %q, want Spinning...", got)
if got := call.Form["caption"]; got != `Result: <span class="tg-spoiler">Alice</span>` {
t.Fatalf("caption = %q, want result spoiler", got)
}
if strings.Contains(call.Form["caption"], "Alice") {
t.Fatalf("caption spoils winner: %q", call.Form["caption"])
if got := call.Form["parse_mode"]; got != "HTML" {
t.Fatalf("parse_mode = %q, want HTML", got)
}
if got := call.Form["duration"]; got != "10" {
t.Fatalf("duration = %q, want local duration 10", got)
@@ -613,9 +535,9 @@ func TestWheelOfNamesBeta_RemoteFailureFallsBackToLocalAnimation(t *testing.T) {
}
}
func TestWheelOfNamesBeta_ForwardsMessageThreadID(t *testing.T) {
func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) {
rb, _ := installMisc(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnamesbeta Alice")
update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice")
update.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), update)
+3 -4
View File
@@ -1,8 +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), /wheelofnamesbeta (public GIF wheel picker), /the_answer
// (private easter egg), and small public disclaimer commands.
// read), /random (public random picker), /wheelofnames (public GIF wheel
// picker), /the_answer (private easter egg), and small public disclaimer
// commands.
package misc
import (
@@ -54,7 +54,6 @@ func New(deps modules.Deps) modules.Module {
pingStatsCommand(store),
randomCommand(),
wheelOfNamesCommand(),
wheelOfNamesBetaCommand(),
theAnswerCommand(),
disclaimerCommand("trongtruonghop", "Phát biểu disclaimer mặc định", defaultTarget, true),
disclaimerCommand("tth", "Phát biểu disclaimer mặc định", defaultTarget, true),
-1
View File
@@ -25,7 +25,6 @@ func TestNew_RegistersExpectedCommands(t *testing.T) {
"ping_stats": modules.VisibilityProtected,
"random": modules.VisibilityPublic,
"wheelofnames": modules.VisibilityPublic,
"wheelofnamesbeta": modules.VisibilityPublic,
"the_answer": modules.VisibilityPrivate,
"trongtruonghop": modules.VisibilityPublic,
"tth": modules.VisibilityPublic,
+1 -94
View File
@@ -2,25 +2,17 @@ package misc
import (
"context"
"fmt"
"math/rand/v2"
"strings"
"time"
"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 (
randomUsage = "Usage: /random <option1>, <option2>, ..."
wheelOfNamesUsage = "Usage: /wheelofnames <option1>, <option2>, ..."
)
var wheelDraftFrameDelay = 500 * time.Millisecond
const randomUsage = "Usage: /random <option1>, <option2>, ..."
func splitWheelOptions(arg string) []string {
parts := strings.Split(arg, ",")
@@ -51,91 +43,6 @@ func randomCommand() modules.Command {
}
}
func wheelOfNamesCommand() modules.Command {
return modules.Command{
Name: "wheelofnames",
Visibility: modules.VisibilityPublic,
Description: "Spin a suspenseful wheel 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, wheelOfNamesUsage)
}
winner := pickWheelOption(options)
streamWheelDrafts(ctx, b, update.Message, options, winner)
return chathelper.Reply(ctx, b, update.Message, options[winner])
},
}
}
func pickWheelOption(options []string) int {
return rand.N(len(options))
}
func streamWheelDrafts(ctx context.Context, b *bot.Bot, msg *models.Message, options []string, winner int) {
if msg.Chat.Type != models.ChatTypePrivate {
return
}
draftID := fmt.Sprintf("%d", time.Now().UTC().UnixNano())
frames := wheelDraftFrames(options, winner)
for i, text := range frames {
ok, err := b.SendMessageDraft(ctx, &bot.SendMessageDraftParams{
ChatID: msg.Chat.ID,
MessageThreadID: msg.MessageThreadID,
DraftID: draftID,
Text: text,
})
if err != nil {
log.Warn("wheelofnames draft stream failed", "chat", msg.Chat.ID, "err", err)
return
}
if !ok {
log.Warn("wheelofnames draft stream returned false", "chat", msg.Chat.ID)
return
}
if i < len(frames)-1 && !waitWheelDraftFrame(ctx) {
return
}
}
}
func wheelDraftFrames(options []string, winner int) []string {
candidates := []string{
options[(winner+1)%len(options)],
options[(winner+2)%len(options)],
options[(winner+3)%len(options)],
options[(winner+4)%len(options)],
options[(winner+5)%len(options)],
options[winner],
}
labels := []string{
"Spinning the wheel...",
"Still spinning...",
"Picking up speed...",
"Last few names...",
"Slowing down...",
"Almost there...",
}
frames := make([]string, 0, len(labels))
for i, label := range labels {
frames = append(frames, fmt.Sprintf("%s\n> %s", label, candidates[i]))
}
return frames
}
func waitWheelDraftFrame(ctx context.Context) bool {
if wheelDraftFrameDelay <= 0 {
return true
}
timer := time.NewTimer(wheelDraftFrameDelay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
@@ -0,0 +1,172 @@
package misc
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"math"
)
const (
wheelSize = 320
wheelRadius = 118
wheelSpinDuration = 7
wheelHoldDuration = 3
wheelSpinDelay = 20
wheelSpinFrames = wheelSpinDuration * 100 / wheelSpinDelay
wheelHoldFrames = wheelHoldDuration * 100 / wheelSpinDelay
wheelHoldDelay = wheelSpinDelay
wheelDuration = wheelSpinDuration + wheelHoldDuration
wheelCelebrateFrames = 8
wheelPointerAngle = 0.0
)
const (
wheelBackgroundColorIndex byte = 0
wheelInkColorIndex byte = 1
wheelPaperColorIndex byte = 2
wheelShadowColorIndex byte = 10
wheelBevelColorIndex byte = 11
wheelHighlightColorIndex byte = 12
wheelSparkColorIndex byte = 13
)
var wheelPalette = 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},
color.RGBA{R: 204, G: 212, B: 224, A: 255},
color.RGBA{R: 82, G: 94, B: 111, A: 255},
color.RGBA{R: 255, G: 244, B: 206, A: 255},
color.RGBA{R: 255, G: 218, B: 89, A: 255},
}
var wheelSliceColorIndexes = []byte{3, 4, 5, 6, 7, 8, 9}
func renderWheelOfNamesGIF(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, wheelSpinFrames+wheelHoldFrames)
delays := make([]int, 0, wheelSpinFrames+wheelHoldFrames)
profile := newWheelSpinProfile(len(options), winner, nil)
for i := 0; i < wheelSpinFrames; i++ {
t := float64(i) / float64(wheelSpinFrames-1)
frames = append(frames, renderWheelFrameWithStatus(options, winner, profile.rotationAt(t), false, profile.statusAt(t)))
delays = append(delays, wheelSpinDelay)
}
for i := 0; i < wheelHoldFrames; i++ {
celebrateStep := i
if celebrateStep >= wheelCelebrateFrames {
celebrateStep = -1
}
frames = append(frames, renderWheelFrameWithCelebration(options, winner, profile.finalRotation, true, "", celebrateStep))
delays = append(delays, wheelHoldDelay)
}
var buf bytes.Buffer
err := gif.EncodeAll(&buf, &gif.GIF{
Image: frames,
Delay: delays,
LoopCount: -1,
Config: image.Config{
ColorModel: wheelPalette,
Width: wheelSize,
Height: wheelSize,
},
})
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func renderWheelFrame(options []string, winner int, rotation float64, reveal bool) *image.Paletted {
return renderWheelFrameWithStatus(options, winner, rotation, reveal, "")
}
func renderWheelFrameWithStatus(options []string, winner int, rotation float64, reveal bool, status string) *image.Paletted {
return renderWheelFrameWithCelebration(options, winner, rotation, reveal, status, -1)
}
func renderWheelFrameWithCelebration(options []string, winner int, rotation float64, reveal bool, status string, celebrateStep int) *image.Paletted {
rect := image.Rect(0, 0, wheelSize, wheelSize)
img := image.NewPaletted(rect, wheelPalette)
draw.Draw(img, rect, image.NewUniform(wheelPalette[wheelBackgroundColorIndex]), image.Point{}, draw.Src)
cx, cy := wheelSize/2, wheelSize/2
drawWheelDropShadow(img, cx, cy)
segment := 2 * math.Pi / float64(len(options))
r2 := wheelRadius * wheelRadius
for y := cy - wheelRadius; y <= cy+wheelRadius; y++ {
for x := cx - wheelRadius; x <= cx+wheelRadius; 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 := wheelSliceColorIndexes[idx%len(wheelSliceColorIndexes)]
img.SetColorIndex(x, y, colorIndex)
}
}
currentIndex := currentWheelIndex(len(options), rotation)
pointerColor := wheelSliceColorIndexes[currentIndex%len(wheelSliceColorIndexes)]
drawWheelLighting(img, cx, cy)
drawWheelRim(img, cx, cy)
drawWinnerCelebration(img, celebrateStep)
drawWheelSliceLabels(img, options, rotation)
drawCircle(img, cx, cy, 24, wheelPaperColorIndex)
drawCircle(img, cx, cy, 18, wheelInkColorIndex)
drawPointer(img, cx+wheelRadius, cy, pointerColor)
drawCenteredText(img, "WHEELOFNAMES", cy+wheelRadius+34, wheelInkColorIndex)
label := status
if label == "" {
label = "CURRENT"
}
value := wheelDisplayText(options[currentIndex], 28)
if reveal {
label = "RESULT"
value = wheelDisplayText(options[winner], 28)
}
drawStatusBand(img, label, value)
return img
}
func finalWheelRotation(optionCount, winner int) float64 {
return finalWheelRotationWithOffset(optionCount, winner, 0)
}
func finalWheelRotationWithOffset(optionCount, winner int, sliceOffset float64) float64 {
segment := 2 * math.Pi / float64(optionCount)
return wheelPointerAngle - (float64(winner)+0.5+sliceOffset)*segment
}
func currentWheelIndex(optionCount int, rotation float64) int {
segment := 2 * math.Pi / float64(optionCount)
return int(normalizeAngle(wheelPointerAngle-rotation) / segment)
}
func normalizeAngle(theta float64) float64 {
theta = math.Mod(theta, 2*math.Pi)
if theta < 0 {
theta += 2 * math.Pi
}
return theta
}
@@ -0,0 +1,187 @@
package misc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/tiennm99/miti99bot/internal/log"
)
const (
wheelOfNamesAPIURLEnv = "WHEELOFNAMES_API_URL"
wheelOfNamesAPITokenEnv = "WHEELOFNAMES_API_TOKEN"
wheelRemoteDurationMs = 6000
wheelRemoteHoldMs = 1000
wheelRemoteFPS = 20
wheelRemoteSize = 512
wheelRemoteTheme = "classic"
wheelRemoteDuration = (wheelRemoteDurationMs + wheelRemoteHoldMs) / 1000
wheelRemoteMaxBytes = 12 << 20
wheelRemoteTimeout = 30 * time.Second
)
var errWheelAPINotConfigured = errors.New("wheelofnames api not configured")
type wheelAPIClient struct {
HTTP *http.Client
URL string
Token string
}
type wheelAPIRequest struct {
Options []string `json:"options"`
WinnerIndex int `json:"winnerIndex"`
DurationMs int `json:"durationMs"`
HoldMs int `json:"holdMs"`
FPS int `json:"fps"`
Size int `json:"size"`
Theme string `json:"theme"`
}
type wheelAnimation struct {
Data []byte
Duration int
Width int
Height int
}
func newWheelAPIClientFromEnv() wheelAPIClient {
return wheelAPIClient{
URL: strings.TrimSpace(os.Getenv(wheelOfNamesAPIURLEnv)),
Token: strings.TrimSpace(os.Getenv(wheelOfNamesAPITokenEnv)),
}
}
func (c wheelAPIClient) Render(ctx context.Context, options []string, winner int) ([]byte, error) {
endpoint, err := wheelAPIEndpoint(c.URL)
if err != nil {
return nil, err
}
if len(options) == 0 {
return nil, fmt.Errorf("wheelofnames api options empty")
}
if winner < 0 || winner >= len(options) {
return nil, fmt.Errorf("wheelofnames api winner index %d out of range %d", winner, len(options))
}
body, err := json.Marshal(wheelAPIRequest{
Options: options,
WinnerIndex: winner,
DurationMs: wheelRemoteDurationMs,
HoldMs: wheelRemoteHoldMs,
FPS: wheelRemoteFPS,
Size: wheelRemoteSize,
Theme: wheelRemoteTheme,
})
if err != nil {
return nil, fmt.Errorf("wheelofnames api request encode failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("wheelofnames api request build failed: %w", err)
}
req.Header.Set("Accept", "image/gif")
req.Header.Set("Content-Type", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, errors.New("wheelofnames api request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("wheelofnames api status %d", resp.StatusCode)
}
if err := requireWheelGIFContentType(resp.Header.Get("Content-Type")); err != nil {
return nil, err
}
data, err := io.ReadAll(io.LimitReader(resp.Body, wheelRemoteMaxBytes+1))
if err != nil {
return nil, fmt.Errorf("wheelofnames api response read failed: %w", err)
}
if len(data) > wheelRemoteMaxBytes {
return nil, fmt.Errorf("wheelofnames api response too large")
}
if len(data) == 0 {
return nil, fmt.Errorf("wheelofnames api response empty")
}
if !isWheelGIF(data) {
return nil, fmt.Errorf("wheelofnames api response is not a gif")
}
return data, nil
}
func (c wheelAPIClient) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: wheelRemoteTimeout}
}
func wheelAPIEndpoint(rawURL string) (*url.URL, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, errWheelAPINotConfigured
}
endpoint, err := url.Parse(rawURL)
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, fmt.Errorf("wheelofnames api url invalid")
}
if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
return nil, fmt.Errorf("wheelofnames api url scheme %q unsupported", endpoint.Scheme)
}
return endpoint, nil
}
func requireWheelGIFContentType(contentType string) error {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType != "image/gif" {
return fmt.Errorf("wheelofnames api content type %q unsupported", contentType)
}
return nil
}
func isWheelGIF(data []byte) bool {
return bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a"))
}
func renderWheelOfNamesAnimation(ctx context.Context, options []string, winner int) (wheelAnimation, error) {
client := newWheelAPIClientFromEnv()
if data, err := client.Render(ctx, options, winner); err == nil {
return wheelAnimation{
Data: data,
Duration: wheelRemoteDuration,
Width: wheelRemoteSize,
Height: wheelRemoteSize,
}, nil
} else if !errors.Is(err, errWheelAPINotConfigured) {
log.Warn("wheelofnames remote render failed", "err", err)
}
data, err := renderWheelOfNamesGIF(options, winner)
if err != nil {
return wheelAnimation{}, err
}
return wheelAnimation{
Data: data,
Duration: wheelDuration,
Width: wheelSize,
Height: wheelSize,
}, nil
}
@@ -11,8 +11,8 @@ import (
"testing"
)
func TestWheelBetaAPIClient_RenderValidRequest(t *testing.T) {
var got wheelBetaAPIRequest
func TestWheelAPIClient_RenderValidRequest(t *testing.T) {
var got wheelAPIRequest
var gotAccept string
var gotAuthorization string
var gotContentType string
@@ -32,7 +32,7 @@ func TestWheelBetaAPIClient_RenderValidRequest(t *testing.T) {
}))
defer server.Close()
client := wheelBetaAPIClient{
client := wheelAPIClient{
HTTP: server.Client(),
URL: server.URL + "/api/gif",
Token: "secret-token",
@@ -65,10 +65,10 @@ func TestWheelBetaAPIClient_RenderValidRequest(t *testing.T) {
if got.WinnerIndex != 1 {
t.Fatalf("winnerIndex = %d, want 1", got.WinnerIndex)
}
assertWheelBetaRemoteDefaults(t, got)
assertWheelRemoteDefaults(t, got)
}
func TestWheelBetaAPIClient_RenderWithoutTokenOmitsAuthorization(t *testing.T) {
func TestWheelAPIClient_RenderWithoutTokenOmitsAuthorization(t *testing.T) {
var gotAuthorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuthorization = r.Header.Get("Authorization")
@@ -77,7 +77,7 @@ func TestWheelBetaAPIClient_RenderWithoutTokenOmitsAuthorization(t *testing.T) {
}))
defer server.Close()
client := wheelBetaAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err != nil {
t.Fatalf("Render: %v", err)
}
@@ -86,16 +86,16 @@ func TestWheelBetaAPIClient_RenderWithoutTokenOmitsAuthorization(t *testing.T) {
}
}
func TestWheelBetaAPIClient_RenderNotConfigured(t *testing.T) {
client := wheelBetaAPIClient{}
func TestWheelAPIClient_RenderNotConfigured(t *testing.T) {
client := wheelAPIClient{}
_, err := client.Render(context.Background(), []string{"alice"}, 0)
if !errors.Is(err, errWheelBetaAPINotConfigured) {
t.Fatalf("Render error = %v, want errWheelBetaAPINotConfigured", err)
if !errors.Is(err, errWheelAPINotConfigured) {
t.Fatalf("Render error = %v, want errWheelAPINotConfigured", err)
}
}
func TestWheelBetaAPIClient_RenderRejectsInvalidInput(t *testing.T) {
client := wheelBetaAPIClient{URL: "https://example.com/api/gif"}
func TestWheelAPIClient_RenderRejectsInvalidInput(t *testing.T) {
client := wheelAPIClient{URL: "https://example.com/api/gif"}
for _, tc := range []struct {
name string
url string
@@ -115,7 +115,7 @@ func TestWheelBetaAPIClient_RenderRejectsInvalidInput(t *testing.T) {
}
}
func TestWheelBetaAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
func TestWheelAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
for _, tc := range []struct {
name string
status int
@@ -136,7 +136,7 @@ func TestWheelBetaAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
}))
defer server.Close()
client := wheelBetaAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil {
t.Fatalf("Render returned nil error")
}
@@ -144,41 +144,41 @@ func TestWheelBetaAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
}
}
func TestWheelBetaAPIClient_RenderRejectsOversizedResponse(t *testing.T) {
func TestWheelAPIClient_RenderRejectsOversizedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write(bytes.Repeat([]byte("a"), int(wheelBetaRemoteMaxBytes)+1))
_, _ = w.Write(bytes.Repeat([]byte("a"), int(wheelRemoteMaxBytes)+1))
}))
defer server.Close()
client := wheelBetaAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil {
t.Fatalf("Render returned nil error")
}
}
func TestWheelBetaAPIClient_DefaultHTTPClientHasTimeout(t *testing.T) {
client := wheelBetaAPIClient{}
if got := client.httpClient().Timeout; got != wheelBetaRemoteTimeout {
t.Fatalf("timeout = %s, want %s", got, wheelBetaRemoteTimeout)
func TestWheelAPIClient_DefaultHTTPClientHasTimeout(t *testing.T) {
client := wheelAPIClient{}
if got := client.httpClient().Timeout; got != wheelRemoteTimeout {
t.Fatalf("timeout = %s, want %s", got, wheelRemoteTimeout)
}
}
func assertWheelBetaRemoteDefaults(t *testing.T, got wheelBetaAPIRequest) {
func assertWheelRemoteDefaults(t *testing.T, got wheelAPIRequest) {
t.Helper()
if got.DurationMs != wheelBetaRemoteDurationMs {
t.Fatalf("durationMs = %d, want %d", got.DurationMs, wheelBetaRemoteDurationMs)
if got.DurationMs != wheelRemoteDurationMs {
t.Fatalf("durationMs = %d, want %d", got.DurationMs, wheelRemoteDurationMs)
}
if got.HoldMs != wheelBetaRemoteHoldMs {
t.Fatalf("holdMs = %d, want %d", got.HoldMs, wheelBetaRemoteHoldMs)
if got.HoldMs != wheelRemoteHoldMs {
t.Fatalf("holdMs = %d, want %d", got.HoldMs, wheelRemoteHoldMs)
}
if got.FPS != wheelBetaRemoteFPS {
t.Fatalf("fps = %d, want %d", got.FPS, wheelBetaRemoteFPS)
if got.FPS != wheelRemoteFPS {
t.Fatalf("fps = %d, want %d", got.FPS, wheelRemoteFPS)
}
if got.Size != wheelBetaRemoteSize {
t.Fatalf("size = %d, want %d", got.Size, wheelBetaRemoteSize)
if got.Size != wheelRemoteSize {
t.Fatalf("size = %d, want %d", got.Size, wheelRemoteSize)
}
if got.Theme != wheelBetaRemoteTheme {
t.Fatalf("theme = %q, want %q", got.Theme, wheelBetaRemoteTheme)
if got.Theme != wheelRemoteTheme {
t.Fatalf("theme = %q, want %q", got.Theme, wheelRemoteTheme)
}
}
-172
View File
@@ -1,172 +0,0 @@
package misc
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"math"
)
const (
wheelBetaSize = 320
wheelBetaRadius = 118
wheelBetaSpinDuration = 7
wheelBetaHoldDuration = 3
wheelBetaSpinDelay = 20
wheelBetaSpinFrames = wheelBetaSpinDuration * 100 / wheelBetaSpinDelay
wheelBetaHoldFrames = wheelBetaHoldDuration * 100 / wheelBetaSpinDelay
wheelBetaHoldDelay = wheelBetaSpinDelay
wheelBetaDuration = wheelBetaSpinDuration + wheelBetaHoldDuration
wheelBetaCelebrateFrames = 8
wheelBetaPointerAngle = 0.0
)
const (
wheelBetaBackgroundColorIndex byte = 0
wheelBetaInkColorIndex byte = 1
wheelBetaPaperColorIndex byte = 2
wheelBetaShadowColorIndex byte = 10
wheelBetaBevelColorIndex byte = 11
wheelBetaHighlightColorIndex byte = 12
wheelBetaSparkColorIndex byte = 13
)
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},
color.RGBA{R: 204, G: 212, B: 224, A: 255},
color.RGBA{R: 82, G: 94, B: 111, A: 255},
color.RGBA{R: 255, G: 244, B: 206, A: 255},
color.RGBA{R: 255, G: 218, B: 89, A: 255},
}
var wheelBetaSliceColorIndexes = []byte{3, 4, 5, 6, 7, 8, 9}
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+wheelBetaHoldFrames)
delays := make([]int, 0, wheelBetaSpinFrames+wheelBetaHoldFrames)
profile := newWheelBetaSpinProfile(len(options), winner, nil)
for i := 0; i < wheelBetaSpinFrames; i++ {
t := float64(i) / float64(wheelBetaSpinFrames-1)
frames = append(frames, renderWheelBetaFrameWithStatus(options, winner, profile.rotationAt(t), false, profile.statusAt(t)))
delays = append(delays, wheelBetaSpinDelay)
}
for i := 0; i < wheelBetaHoldFrames; i++ {
celebrateStep := i
if celebrateStep >= wheelBetaCelebrateFrames {
celebrateStep = -1
}
frames = append(frames, renderWheelBetaFrameWithCelebration(options, winner, profile.finalRotation, true, "", celebrateStep))
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 {
return renderWheelBetaFrameWithStatus(options, winner, rotation, reveal, "")
}
func renderWheelBetaFrameWithStatus(options []string, winner int, rotation float64, reveal bool, status string) *image.Paletted {
return renderWheelBetaFrameWithCelebration(options, winner, rotation, reveal, status, -1)
}
func renderWheelBetaFrameWithCelebration(options []string, winner int, rotation float64, reveal bool, status string, celebrateStep int) *image.Paletted {
rect := image.Rect(0, 0, wheelBetaSize, wheelBetaSize)
img := image.NewPaletted(rect, wheelBetaPalette)
draw.Draw(img, rect, image.NewUniform(wheelBetaPalette[wheelBetaBackgroundColorIndex]), image.Point{}, draw.Src)
cx, cy := wheelBetaSize/2, wheelBetaSize/2
drawWheelDropShadow(img, cx, cy)
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 := wheelBetaSliceColorIndexes[idx%len(wheelBetaSliceColorIndexes)]
img.SetColorIndex(x, y, colorIndex)
}
}
currentIndex := currentWheelBetaIndex(len(options), rotation)
pointerColor := wheelBetaSliceColorIndexes[currentIndex%len(wheelBetaSliceColorIndexes)]
drawWheelLighting(img, cx, cy)
drawWheelRim(img, cx, cy)
drawWinnerCelebration(img, celebrateStep)
drawWheelSliceLabels(img, options, rotation)
drawCircle(img, cx, cy, 24, wheelBetaPaperColorIndex)
drawCircle(img, cx, cy, 18, wheelBetaInkColorIndex)
drawPointer(img, cx+wheelBetaRadius, cy, pointerColor)
drawCenteredText(img, "WHEELOFNAMES BETA", cy+wheelBetaRadius+34, wheelBetaInkColorIndex)
label := status
if label == "" {
label = "CURRENT"
}
value := wheelBetaDisplayText(options[currentIndex], 28)
if reveal {
label = "RESULT"
value = wheelBetaDisplayText(options[winner], 28)
}
drawStatusBand(img, label, value)
return img
}
func finalWheelRotation(optionCount, winner int) float64 {
return finalWheelRotationWithOffset(optionCount, winner, 0)
}
func finalWheelRotationWithOffset(optionCount, winner int, sliceOffset float64) float64 {
segment := 2 * math.Pi / float64(optionCount)
return wheelBetaPointerAngle - (float64(winner)+0.5+sliceOffset)*segment
}
func currentWheelBetaIndex(optionCount int, rotation float64) int {
segment := 2 * math.Pi / float64(optionCount)
return int(normalizeAngle(wheelBetaPointerAngle-rotation) / segment)
}
func normalizeAngle(theta float64) float64 {
theta = math.Mod(theta, 2*math.Pi)
if theta < 0 {
theta += 2 * math.Pi
}
return theta
}
@@ -1,187 +0,0 @@
package misc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/tiennm99/miti99bot/internal/log"
)
const (
wheelOfNamesBetaAPIURLEnv = "WHEELOFNAMES_API_URL"
wheelOfNamesBetaAPITokenEnv = "WHEELOFNAMES_API_TOKEN"
wheelBetaRemoteDurationMs = 6000
wheelBetaRemoteHoldMs = 1000
wheelBetaRemoteFPS = 20
wheelBetaRemoteSize = 512
wheelBetaRemoteTheme = "classic"
wheelBetaRemoteDuration = (wheelBetaRemoteDurationMs + wheelBetaRemoteHoldMs) / 1000
wheelBetaRemoteMaxBytes = 12 << 20
wheelBetaRemoteTimeout = 30 * time.Second
)
var errWheelBetaAPINotConfigured = errors.New("wheelofnamesbeta api not configured")
type wheelBetaAPIClient struct {
HTTP *http.Client
URL string
Token string
}
type wheelBetaAPIRequest struct {
Options []string `json:"options"`
WinnerIndex int `json:"winnerIndex"`
DurationMs int `json:"durationMs"`
HoldMs int `json:"holdMs"`
FPS int `json:"fps"`
Size int `json:"size"`
Theme string `json:"theme"`
}
type wheelBetaAnimation struct {
Data []byte
Duration int
Width int
Height int
}
func newWheelBetaAPIClientFromEnv() wheelBetaAPIClient {
return wheelBetaAPIClient{
URL: strings.TrimSpace(os.Getenv(wheelOfNamesBetaAPIURLEnv)),
Token: strings.TrimSpace(os.Getenv(wheelOfNamesBetaAPITokenEnv)),
}
}
func (c wheelBetaAPIClient) Render(ctx context.Context, options []string, winner int) ([]byte, error) {
endpoint, err := wheelBetaAPIEndpoint(c.URL)
if err != nil {
return nil, err
}
if len(options) == 0 {
return nil, fmt.Errorf("wheelofnamesbeta api options empty")
}
if winner < 0 || winner >= len(options) {
return nil, fmt.Errorf("wheelofnamesbeta api winner index %d out of range %d", winner, len(options))
}
body, err := json.Marshal(wheelBetaAPIRequest{
Options: options,
WinnerIndex: winner,
DurationMs: wheelBetaRemoteDurationMs,
HoldMs: wheelBetaRemoteHoldMs,
FPS: wheelBetaRemoteFPS,
Size: wheelBetaRemoteSize,
Theme: wheelBetaRemoteTheme,
})
if err != nil {
return nil, fmt.Errorf("wheelofnamesbeta api request encode failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("wheelofnamesbeta api request build failed: %w", err)
}
req.Header.Set("Accept", "image/gif")
req.Header.Set("Content-Type", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, errors.New("wheelofnamesbeta api request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("wheelofnamesbeta api status %d", resp.StatusCode)
}
if err := requireWheelBetaGIFContentType(resp.Header.Get("Content-Type")); err != nil {
return nil, err
}
data, err := io.ReadAll(io.LimitReader(resp.Body, wheelBetaRemoteMaxBytes+1))
if err != nil {
return nil, fmt.Errorf("wheelofnamesbeta api response read failed: %w", err)
}
if len(data) > wheelBetaRemoteMaxBytes {
return nil, fmt.Errorf("wheelofnamesbeta api response too large")
}
if len(data) == 0 {
return nil, fmt.Errorf("wheelofnamesbeta api response empty")
}
if !isWheelBetaGIF(data) {
return nil, fmt.Errorf("wheelofnamesbeta api response is not a gif")
}
return data, nil
}
func (c wheelBetaAPIClient) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: wheelBetaRemoteTimeout}
}
func wheelBetaAPIEndpoint(rawURL string) (*url.URL, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, errWheelBetaAPINotConfigured
}
endpoint, err := url.Parse(rawURL)
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, fmt.Errorf("wheelofnamesbeta api url invalid")
}
if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
return nil, fmt.Errorf("wheelofnamesbeta api url scheme %q unsupported", endpoint.Scheme)
}
return endpoint, nil
}
func requireWheelBetaGIFContentType(contentType string) error {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType != "image/gif" {
return fmt.Errorf("wheelofnamesbeta api content type %q unsupported", contentType)
}
return nil
}
func isWheelBetaGIF(data []byte) bool {
return bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a"))
}
func renderWheelOfNamesBetaAnimation(ctx context.Context, options []string, winner int) (wheelBetaAnimation, error) {
client := newWheelBetaAPIClientFromEnv()
if data, err := client.Render(ctx, options, winner); err == nil {
return wheelBetaAnimation{
Data: data,
Duration: wheelBetaRemoteDuration,
Width: wheelBetaRemoteSize,
Height: wheelBetaRemoteSize,
}, nil
} else if !errors.Is(err, errWheelBetaAPINotConfigured) {
log.Warn("wheelofnamesbeta remote render failed", "err", err)
}
data, err := renderWheelOfNamesBetaGIF(options, winner)
if err != nil {
return wheelBetaAnimation{}, err
}
return wheelBetaAnimation{
Data: data,
Duration: wheelBetaDuration,
Width: wheelBetaSize,
Height: wheelBetaSize,
}, nil
}
@@ -1,58 +0,0 @@
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)
animation, err := renderWheelOfNamesBetaAnimation(ctx, 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(animation.Data),
},
Duration: animation.Duration,
Width: animation.Width,
Height: animation.Height,
Caption: "Spinning...",
})
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
},
}
}
@@ -0,0 +1,74 @@
package misc
import (
"bytes"
"context"
"html"
"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 (
wheelOfNamesUsage = "Usage: /wheelofnames <option1>, <option2>, ..."
wheelFilename = "wheelofnames.gif"
wheelResultCaptionMaxRunes = 900
)
func wheelOfNamesCommand() modules.Command {
return modules.Command{
Name: "wheelofnames",
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, wheelOfNamesUsage)
}
winner := pickWheelOption(options)
animation, err := renderWheelOfNamesAnimation(ctx, options, winner)
if err != nil {
log.Error("wheelofnames render failed", "err", err)
return chathelper.ReplyHTML(ctx, b, update.Message, wheelResultCaption(options[winner]))
}
_, err = b.SendAnimation(ctx, &bot.SendAnimationParams{
ChatID: update.Message.Chat.ID,
MessageThreadID: update.Message.MessageThreadID,
Animation: &models.InputFileUpload{
Filename: wheelFilename,
Data: bytes.NewReader(animation.Data),
},
Duration: animation.Duration,
Width: animation.Width,
Height: animation.Height,
Caption: wheelResultCaption(options[winner]),
ParseMode: models.ParseModeHTML,
})
if err != nil {
log.Warn("wheelofnames send animation failed", "chat", update.Message.Chat.ID, "err", err)
return chathelper.ReplyHTML(ctx, b, update.Message, wheelResultCaption(options[winner]))
}
return nil
},
}
}
func wheelResultCaption(result string) string {
result = truncateWheelResultCaption(result)
return `Result: <span class="tg-spoiler">` + html.EscapeString(result) + `</span>`
}
func truncateWheelResultCaption(result string) string {
runes := []rune(result)
if len(runes) <= wheelResultCaptionMaxRunes {
return result
}
return string(runes[:wheelResultCaptionMaxRunes]) + "..."
}
@@ -14,27 +14,27 @@ import (
)
const (
wheelBetaSliceLabelRadius = 64
wheelSliceLabelRadius = 64
)
func drawWheelSliceLabels(img *image.Paletted, options []string, rotation float64) {
if len(options) == 0 {
return
}
cx, cy := wheelBetaSize/2, wheelBetaSize/2
cx, cy := wheelSize/2, wheelSize/2
segment := 2 * math.Pi / float64(len(options))
limit := wheelBetaSliceLabelLimit(len(options))
limit := wheelSliceLabelLimit(len(options))
for idx, option := range options {
angle := rotation + (float64(idx)+0.5)*segment
centerX := cx + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius)))
centerY := cy + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius)))
text := wheelBetaDisplayText(option, limit)
drawRotatedCenteredTextAt(img, text, centerX+1, centerY+1, angle, wheelBetaPaperColorIndex)
drawRotatedCenteredTextAt(img, text, centerX, centerY, angle, wheelBetaInkColorIndex)
centerX := cx + int(math.Round(math.Cos(angle)*float64(wheelSliceLabelRadius)))
centerY := cy + int(math.Round(math.Sin(angle)*float64(wheelSliceLabelRadius)))
text := wheelDisplayText(option, limit)
drawRotatedCenteredTextAt(img, text, centerX+1, centerY+1, angle, wheelPaperColorIndex)
drawRotatedCenteredTextAt(img, text, centerX, centerY, angle, wheelInkColorIndex)
}
}
func wheelBetaSliceLabelLimit(optionCount int) int {
func wheelSliceLabelLimit(optionCount int) int {
switch {
case optionCount <= 2:
return 14
@@ -60,8 +60,8 @@ func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) {
}
func drawWheelDropShadow(img *image.Paletted, cx, cy int) {
rx := wheelBetaRadius + 9
ry := wheelBetaRadius + 5
rx := wheelRadius + 9
ry := wheelRadius + 5
shadowCY := cy + 8
limit := rx * rx * ry * ry
for y := shadowCY - ry; y <= shadowCY+ry; y++ {
@@ -69,28 +69,28 @@ func drawWheelDropShadow(img *image.Paletted, cx, cy int) {
dx := x - cx
dy := y - shadowCY
if dx*dx*ry*ry+dy*dy*rx*rx <= limit {
setWheelBetaPixel(img, x, y, wheelBetaShadowColorIndex)
setWheelPixel(img, x, y, wheelShadowColorIndex)
}
}
}
}
func drawWheelLighting(img *image.Paletted, cx, cy int) {
outer := wheelBetaRadius * wheelBetaRadius
edgeStart := wheelBetaRadius - 13
outer := wheelRadius * wheelRadius
edgeStart := wheelRadius - 13
edge := edgeStart * edgeStart
for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ {
for x := cx - wheelBetaRadius; x <= cx+wheelBetaRadius; x++ {
for y := cy - wheelRadius; y <= cy+wheelRadius; y++ {
for x := cx - wheelRadius; x <= cx+wheelRadius; x++ {
dx, dy := x-cx, y-cy
d2 := dx*dx + dy*dy
if d2 > outer || d2 < edge {
continue
}
if dx+dy > wheelBetaRadius/3 {
img.SetColorIndex(x, y, wheelBetaBevelColorIndex)
if dx+dy > wheelRadius/3 {
img.SetColorIndex(x, y, wheelBevelColorIndex)
}
if dx+dy < -wheelBetaRadius {
img.SetColorIndex(x, y, wheelBetaHighlightColorIndex)
if dx+dy < -wheelRadius {
img.SetColorIndex(x, y, wheelHighlightColorIndex)
}
}
}
@@ -105,16 +105,16 @@ func drawHighlightOval(img *image.Paletted, cx, cy, rx, ry int) {
dx := x - cx
dy := y - cy
if dx*dx*ry*ry+dy*dy*rx*rx <= limit {
setWheelBetaPixel(img, x, y, wheelBetaHighlightColorIndex)
setWheelPixel(img, x, y, wheelHighlightColorIndex)
}
}
}
}
func drawWheelRim(img *image.Paletted, cx, cy int) {
drawCircleOutline(img, cx, cy, wheelBetaRadius, 3, wheelBetaInkColorIndex)
drawCircleOutline(img, cx, cy, wheelBetaRadius-5, 1, wheelBetaBevelColorIndex)
drawCircleOutline(img, cx, cy, wheelBetaRadius-8, 1, wheelBetaHighlightColorIndex)
drawCircleOutline(img, cx, cy, wheelRadius, 3, wheelInkColorIndex)
drawCircleOutline(img, cx, cy, wheelRadius-5, 1, wheelBevelColorIndex)
drawCircleOutline(img, cx, cy, wheelRadius-8, 1, wheelHighlightColorIndex)
}
func drawCircleOutline(img *image.Paletted, cx, cy, radius, thickness int, colorIndex byte) {
@@ -137,7 +137,7 @@ func drawPointer(img *image.Paletted, tipX, cy int, fillColorIndex byte) {
half := xOffset / 2
x := tipX + xOffset
for y := cy - half; y <= cy+half; y++ {
setWheelBetaPixel(img, x, y, wheelBetaInkColorIndex)
setWheelPixel(img, x, y, wheelInkColorIndex)
}
}
for xOffset := 3; xOffset < 30; xOffset++ {
@@ -147,7 +147,7 @@ func drawPointer(img *image.Paletted, tipX, cy int, fillColorIndex byte) {
}
x := tipX + xOffset
for y := cy - half; y <= cy+half; y++ {
setWheelBetaPixel(img, x, y, fillColorIndex)
setWheelPixel(img, x, y, fillColorIndex)
}
}
}
@@ -157,24 +157,24 @@ func drawWinnerCelebration(img *image.Paletted, step int) {
return
}
cx, cy := wheelBetaSize/2, wheelBetaSize/2
phase := step % wheelBetaCelebrateFrames
cx, cy := wheelSize/2, wheelSize/2
phase := step % wheelCelebrateFrames
ringRadius := 34 + phase*5
if ringRadius < wheelBetaRadius-8 {
drawCircleOutline(img, cx, cy, ringRadius, 1, wheelBetaSparkColorIndex)
if ringRadius < wheelRadius-8 {
drawCircleOutline(img, cx, cy, ringRadius, 1, wheelSparkColorIndex)
}
for i := 0; i < 14; i++ {
angle := (float64(i)/14)*2*math.Pi + float64(phase)*0.31
inner := float64(wheelBetaRadius + 9 + phase%3)
inner := float64(wheelRadius + 9 + phase%3)
outer := inner + 7 + float64(phase%4)
x1 := cx + int(math.Round(math.Cos(angle)*inner))
y1 := cy + int(math.Round(math.Sin(angle)*inner))
x2 := cx + int(math.Round(math.Cos(angle)*outer))
y2 := cy + int(math.Round(math.Sin(angle)*outer))
colorIndex := wheelBetaSliceColorIndexes[(i+phase)%len(wheelBetaSliceColorIndexes)]
colorIndex := wheelSliceColorIndexes[(i+phase)%len(wheelSliceColorIndexes)]
if i%5 == 0 {
colorIndex = wheelBetaSparkColorIndex
colorIndex = wheelSparkColorIndex
}
drawPalettedLine(img, x1, y1, x2, y2, colorIndex)
drawSpark(img, x2, y2, colorIndex)
@@ -183,25 +183,25 @@ func drawWinnerCelebration(img *image.Paletted, step int) {
func drawStatusBand(img *image.Paletted, label, value string) {
for y := 235; y < 286; y++ {
for x := 31; x < wheelBetaSize-25; x++ {
img.SetColorIndex(x, y, wheelBetaShadowColorIndex)
for x := 31; x < wheelSize-25; x++ {
img.SetColorIndex(x, y, wheelShadowColorIndex)
}
}
for y := 230; y < 282; y++ {
for x := 28; x < wheelBetaSize-28; x++ {
img.SetColorIndex(x, y, wheelBetaPaperColorIndex)
for x := 28; x < wheelSize-28; x++ {
img.SetColorIndex(x, y, wheelPaperColorIndex)
}
}
for x := 28; x < wheelBetaSize-28; x++ {
img.SetColorIndex(x, 230, wheelBetaHighlightColorIndex)
img.SetColorIndex(x, 281, wheelBetaBevelColorIndex)
for x := 28; x < wheelSize-28; x++ {
img.SetColorIndex(x, 230, wheelHighlightColorIndex)
img.SetColorIndex(x, 281, wheelBevelColorIndex)
}
drawCenteredText(img, label, 250, wheelBetaInkColorIndex)
drawCenteredText(img, value, 270, wheelBetaInkColorIndex)
drawCenteredText(img, label, 250, wheelInkColorIndex)
drawCenteredText(img, value, 270, wheelInkColorIndex)
}
func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) {
drawCenteredTextAt(img, text, wheelBetaSize/2, baselineY, colorIndex)
drawCenteredTextAt(img, text, wheelSize/2, baselineY, colorIndex)
}
func drawCenteredTextAt(img *image.Paletted, text string, centerX, baselineY int, colorIndex byte) {
@@ -210,7 +210,7 @@ func drawCenteredTextAt(img *image.Paletted, text string, centerX, baselineY int
x := centerX - width/2
drawer := font.Drawer{
Dst: img,
Src: image.NewUniform(wheelBetaPalette[colorIndex]),
Src: image.NewUniform(wheelPalette[colorIndex]),
Face: face,
Dot: fixed.P(x, baselineY),
}
@@ -244,12 +244,12 @@ func drawRotatedCenteredTextAt(img *image.Paletted, text string, centerX, center
localY := float64(y) - sourceCenterY
targetX := centerX + int(math.Round(localX*cos-localY*sin))
targetY := centerY + int(math.Round(localX*sin+localY*cos))
setWheelBetaPixel(img, targetX, targetY, colorIndex)
setWheelPixel(img, targetX, targetY, colorIndex)
}
}
}
func wheelBetaDisplayText(s string, limit int) string {
func wheelDisplayText(s string, limit int) string {
var b strings.Builder
count := 0
for _, r := range norm.NFD.String(s) {
@@ -259,7 +259,7 @@ func wheelBetaDisplayText(s string, limit int) string {
if count >= limit {
break
}
r = wheelBetaASCIIRune(r)
r = wheelASCIIRune(r)
if unicode.IsControl(r) {
b.WriteByte('?')
count++
@@ -280,7 +280,7 @@ func wheelBetaDisplayText(s string, limit int) string {
return out
}
func wheelBetaASCIIRune(r rune) rune {
func wheelASCIIRune(r rune) rune {
switch r {
case 'đ':
return 'd'
@@ -295,7 +295,7 @@ func drawSpark(img *image.Paletted, cx, cy int, colorIndex byte) {
for y := cy - 1; y <= cy+1; y++ {
for x := cx - 1; x <= cx+1; x++ {
if x == cx || y == cy {
setWheelBetaPixel(img, x, y, colorIndex)
setWheelPixel(img, x, y, colorIndex)
}
}
}
@@ -314,7 +314,7 @@ func drawPalettedLine(img *image.Paletted, x0, y0, x1, y1 int, colorIndex byte)
}
err := dx + dy
for {
setWheelBetaPixel(img, x0, y0, colorIndex)
setWheelPixel(img, x0, y0, colorIndex)
if x0 == x1 && y0 == y1 {
return
}
@@ -330,7 +330,7 @@ func drawPalettedLine(img *image.Paletted, x0, y0, x1, y1 int, colorIndex byte)
}
}
func setWheelBetaPixel(img *image.Paletted, x, y int, colorIndex byte) {
func setWheelPixel(img *image.Paletted, x, y int, colorIndex byte) {
if image.Pt(x, y).In(img.Rect) {
img.SetColorIndex(x, y, colorIndex)
}
@@ -6,12 +6,12 @@ import (
)
const (
wheelBetaMinRevolutions = 7
wheelBetaRandomRevolutionRange = 5
wheelBetaMaxLandingOffset = 0.34
wheelMinRevolutions = 7
wheelRandomRevolutionRange = 5
wheelMaxLandingOffset = 0.34
)
type wheelBetaSpinProfile struct {
type wheelSpinProfile struct {
startRotation float64
finalRotation float64
accelEnd float64
@@ -21,31 +21,31 @@ type wheelBetaSpinProfile struct {
wobblePhase float64
}
func newWheelBetaSpinProfile(optionCount, winner int, rng *rand.Rand) wheelBetaSpinProfile {
func newWheelSpinProfile(optionCount, winner int, rng *rand.Rand) wheelSpinProfile {
segment := 2 * math.Pi / float64(optionCount)
landingOffset := (wheelBetaRandFloat64(rng)*2 - 1) * wheelBetaMaxLandingOffset
landingOffset := (wheelRandFloat64(rng)*2 - 1) * wheelMaxLandingOffset
finalRotation := finalWheelRotationWithOffset(optionCount, winner, landingOffset)
revolutions := wheelBetaMinRevolutions + wheelBetaRandIntN(rng, wheelBetaRandomRevolutionRange)
accelEnd := 0.20 + wheelBetaRandFloat64(rng)*0.1
return wheelBetaSpinProfile{
revolutions := wheelMinRevolutions + wheelRandIntN(rng, wheelRandomRevolutionRange)
accelEnd := 0.20 + wheelRandFloat64(rng)*0.1
return wheelSpinProfile{
startRotation: finalRotation - float64(revolutions)*2*math.Pi,
finalRotation: finalRotation,
accelEnd: accelEnd,
decelSharpness: 3.6 + wheelBetaRandFloat64(rng)*1.8,
wobbleAmplitude: math.Min(segment*0.075, 0.09) * (0.55 + wheelBetaRandFloat64(rng)*0.45),
wobbleCycles: 3.5 + wheelBetaRandFloat64(rng)*2.5,
wobblePhase: wheelBetaRandFloat64(rng) * 2 * math.Pi,
decelSharpness: 3.6 + wheelRandFloat64(rng)*1.8,
wobbleAmplitude: math.Min(segment*0.075, 0.09) * (0.55 + wheelRandFloat64(rng)*0.45),
wobbleCycles: 3.5 + wheelRandFloat64(rng)*2.5,
wobblePhase: wheelRandFloat64(rng) * 2 * math.Pi,
}
}
func (p wheelBetaSpinProfile) rotationAt(t float64) float64 {
t = clampWheelBetaProgress(t)
func (p wheelSpinProfile) rotationAt(t float64) float64 {
t = clampWheelProgress(t)
progress := p.progressAt(t)
rotation := p.startRotation + (p.finalRotation-p.startRotation)*progress
return rotation + p.wobbleAt(t)
}
func (p wheelBetaSpinProfile) statusAt(t float64) string {
func (p wheelSpinProfile) statusAt(t float64) string {
switch {
case t < p.accelEnd:
return "BUILDING SPEED"
@@ -58,8 +58,8 @@ func (p wheelBetaSpinProfile) statusAt(t float64) string {
}
}
func (p wheelBetaSpinProfile) progressAt(t float64) float64 {
t = clampWheelBetaProgress(t)
func (p wheelSpinProfile) progressAt(t float64) float64 {
t = clampWheelProgress(t)
if t == 0 || t == 1 {
return t
}
@@ -85,7 +85,7 @@ func (p wheelBetaSpinProfile) progressAt(t float64) float64 {
return accelDistance + (1-accelDistance)*decelProgress
}
func (p wheelBetaSpinProfile) wobbleAt(t float64) float64 {
func (p wheelSpinProfile) wobbleAt(t float64) float64 {
if t <= p.accelEnd || t >= 1 {
return 0
}
@@ -94,7 +94,7 @@ func (p wheelBetaSpinProfile) wobbleAt(t float64) float64 {
return p.wobbleAmplitude * envelope * math.Sin(p.wobblePhase+u*p.wobbleCycles*2*math.Pi)
}
func clampWheelBetaProgress(t float64) float64 {
func clampWheelProgress(t float64) float64 {
switch {
case t < 0:
return 0
@@ -105,14 +105,14 @@ func clampWheelBetaProgress(t float64) float64 {
}
}
func wheelBetaRandFloat64(rng *rand.Rand) float64 {
func wheelRandFloat64(rng *rand.Rand) float64 {
if rng != nil {
return rng.Float64()
}
return rand.Float64()
}
func wheelBetaRandIntN(rng *rand.Rand, n int) int {
func wheelRandIntN(rng *rand.Rand, n int) int {
if n <= 0 {
return 0
}
+53
View File
@@ -23,6 +23,9 @@ const (
renameLolNextWeekStatsKey = "stats:command-rename:lol_nextweek-to-lol_next_week"
oldLolNextWeekCommand = "lol_nextweek"
newLolNextWeekCommand = "lol_next_week"
deleteLegacyWheelOfNamesStatsKey = "stats:command-delete:wheelofnamesbeta"
deletedLegacyWheelOfNamesCommand = "wheelofnamesbeta"
)
// InitStore performs stats collection startup maintenance. It is safe to call
@@ -37,6 +40,9 @@ func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) er
if err := migrateCommandRename(ctx, statsColl, systemColl, oldLolNextWeekCommand, newLolNextWeekCommand, renameLolNextWeekStatsKey); err != nil {
return err
}
if err := markCommandDeleted(ctx, statsColl, systemColl, deletedLegacyWheelOfNamesCommand, deleteLegacyWheelOfNamesStatsKey); err != nil {
return err
}
return nil
}
@@ -114,6 +120,53 @@ func migrateCommandRename(ctx context.Context, statsColl, systemColl storage.Col
return nil
}
func markCommandDeleted(ctx context.Context, statsColl, systemColl storage.Collection, cmd, markerKey string) error {
state := systemstate.New(systemColl)
if rec, ok, err := state.Get(ctx, markerKey); err != nil {
return fmt.Errorf("stats command delete marker %s: %w", markerKey, err)
} else if ok && rec.Status == "complete" {
return nil
}
docs := storage.Typed[usageEntry](statsColl)
keys, err := docs.List(ctx, cmd)
if err != nil {
return fmt.Errorf("stats command delete list %s: %w", cmd, err)
}
var marked int64
for _, key := range keys {
if key != cmd && !strings.HasPrefix(key, cmd+":") {
continue
}
entry, _, err := docs.Get(ctx, key)
if err != nil {
return fmt.Errorf("stats command delete get %s: %w", key, err)
}
if entry.Cmd != cmd || entry.Deleted {
continue
}
entry.Deleted = true
if err := docs.Put(ctx, key, entry); err != nil {
return fmt.Errorf("stats command delete put %s: %w", key, err)
}
marked += entry.N
}
now := time.Now().UTC().UnixMilli()
if err := state.Put(ctx, markerKey, systemstate.Record{
Kind: "migration",
Name: markerKey,
Status: "complete",
Count: marked,
CompletedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("stats command delete marker put %s: %w", markerKey, err)
}
return nil
}
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
models := []mongo.IndexModel{
{
+62
View File
@@ -74,3 +74,65 @@ func TestInitStore_RenamesLolNextWeekStatsOnce(t *testing.T) {
t.Fatalf("migration marker = %+v ok=%v, want complete count 5", rec, ok)
}
}
func TestInitStore_MarksLegacyWheelOfNamesStatsDeletedOnce(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
seeds := map[string]usageEntry{
usageKey(deletedLegacyWheelOfNamesCommand, 0): {Cmd: deletedLegacyWheelOfNamesCommand, N: 2},
usageKey(deletedLegacyWheelOfNamesCommand, 7): {
Cmd: deletedLegacyWheelOfNamesCommand,
UserID: 7,
Username: "alice",
N: 3,
},
usageKey("wheelofnames", 7): {
Cmd: "wheelofnames",
UserID: 7,
Username: "alice",
N: 5,
},
}
for key, entry := range seeds {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore second run: %v", err)
}
for _, key := range []string{usageKey(deletedLegacyWheelOfNamesCommand, 0), usageKey(deletedLegacyWheelOfNamesCommand, 7)} {
entry, _, err := docs.Get(ctx, key)
if err != nil {
t.Fatalf("legacy stats %s: %v", key, err)
}
if !entry.Deleted {
t.Fatalf("legacy stats %s = %+v, want deleted", key, entry)
}
}
active, _, err := docs.Get(ctx, usageKey("wheelofnames", 7))
if err != nil {
t.Fatalf("active wheelofnames stats: %v", err)
}
if active.Deleted {
t.Fatalf("active wheelofnames stats = %+v, want not deleted", active)
}
rec, ok, err := systemstate.New(systemColl).Get(ctx, deleteLegacyWheelOfNamesStatsKey)
if err != nil {
t.Fatalf("migration marker: %v", err)
}
if !ok || rec.Status != "complete" || rec.Count != 5 {
t.Fatalf("migration marker = %+v ok=%v, want complete count 5", rec, ok)
}
}
-4
View File
@@ -14,10 +14,6 @@
},
{
"command": "wheelofnames",
"description": "Spin a suspenseful wheel for comma-separated options"
},
{
"command": "wheelofnamesbeta",
"description": "Send an animated wheel GIF for comma-separated options"
},
{