From 0bd7d71513a4f792ce00f6c5a761029082dcf63a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 9 Jul 2026 13:14:11 +0700 Subject: [PATCH] feat(misc): promote wheelofnames gif command --- .env.example | 2 +- README.md | 2 +- docs/deploy-coolify-selfhosted.md | 8 +- internal/modules/misc/handlers_test.go | 280 +++++++----------- internal/modules/misc/misc.go | 7 +- internal/modules/misc/misc_test.go | 1 - internal/modules/misc/random_picker.go | 95 +----- .../modules/misc/wheelofnames_animation.go | 172 +++++++++++ .../modules/misc/wheelofnames_api_client.go | 187 ++++++++++++ ...est.go => wheelofnames_api_client_test.go} | 64 ++-- internal/modules/misc/wheelofnames_beta.go | 172 ----------- .../misc/wheelofnames_beta_api_client.go | 187 ------------ .../modules/misc/wheelofnames_beta_command.go | 58 ---- internal/modules/misc/wheelofnames_command.go | 74 +++++ ...eta_drawing.go => wheelofnames_drawing.go} | 102 +++---- ...rofile.go => wheelofnames_spin_profile.go} | 44 +-- internal/modules/stats/startup.go | 53 ++++ internal/modules/stats/startup_test.go | 62 ++++ telegram-commands.json | 4 - 19 files changed, 764 insertions(+), 810 deletions(-) create mode 100644 internal/modules/misc/wheelofnames_animation.go create mode 100644 internal/modules/misc/wheelofnames_api_client.go rename internal/modules/misc/{wheelofnames_beta_api_client_test.go => wheelofnames_api_client_test.go} (69%) delete mode 100644 internal/modules/misc/wheelofnames_beta.go delete mode 100644 internal/modules/misc/wheelofnames_beta_api_client.go delete mode 100644 internal/modules/misc/wheelofnames_beta_command.go create mode 100644 internal/modules/misc/wheelofnames_command.go rename internal/modules/misc/{wheelofnames_beta_drawing.go => wheelofnames_drawing.go} (69%) rename internal/modules/misc/{wheelofnames_beta_spin_profile.go => wheelofnames_spin_profile.go} (61%) diff --git a/.env.example b/.env.example index ef441af..53d53cd 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index a7a3cdb..fc57077 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Atlas via long polling and an in-process cron scheduler. | Module | What it does | |---|---| | `util` | `/help`, `/info`, `/stickerid` | -| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/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 | diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index d0fd138..20655ee 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -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= 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) diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 4684815..f5d1358 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -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: Alice` { + 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(``) + want := `Result: <Alice & Bob>` + 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: ` + strings.Repeat("a", wheelResultCaptionMaxRunes) + `...` + 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: Alice` { + 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) diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index 6fc3be8..aaad6bf 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -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), diff --git a/internal/modules/misc/misc_test.go b/internal/modules/misc/misc_test.go index 5f8e415..dc3416b 100644 --- a/internal/modules/misc/misc_test.go +++ b/internal/modules/misc/misc_test.go @@ -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, diff --git a/internal/modules/misc/random_picker.go b/internal/modules/misc/random_picker.go index 2aace0a..48acd91 100644 --- a/internal/modules/misc/random_picker.go +++ b/internal/modules/misc/random_picker.go @@ -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 , , ..." - wheelOfNamesUsage = "Usage: /wheelofnames , , ..." -) - -var wheelDraftFrameDelay = 500 * time.Millisecond +const randomUsage = "Usage: /random , , ..." 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 - } -} diff --git a/internal/modules/misc/wheelofnames_animation.go b/internal/modules/misc/wheelofnames_animation.go new file mode 100644 index 0000000..66c48bf --- /dev/null +++ b/internal/modules/misc/wheelofnames_animation.go @@ -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 +} diff --git a/internal/modules/misc/wheelofnames_api_client.go b/internal/modules/misc/wheelofnames_api_client.go new file mode 100644 index 0000000..889fd2b --- /dev/null +++ b/internal/modules/misc/wheelofnames_api_client.go @@ -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 +} diff --git a/internal/modules/misc/wheelofnames_beta_api_client_test.go b/internal/modules/misc/wheelofnames_api_client_test.go similarity index 69% rename from internal/modules/misc/wheelofnames_beta_api_client_test.go rename to internal/modules/misc/wheelofnames_api_client_test.go index 360b4f4..ffa861a 100644 --- a/internal/modules/misc/wheelofnames_beta_api_client_test.go +++ b/internal/modules/misc/wheelofnames_api_client_test.go @@ -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) } } diff --git a/internal/modules/misc/wheelofnames_beta.go b/internal/modules/misc/wheelofnames_beta.go deleted file mode 100644 index fd0bc8a..0000000 --- a/internal/modules/misc/wheelofnames_beta.go +++ /dev/null @@ -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 -} diff --git a/internal/modules/misc/wheelofnames_beta_api_client.go b/internal/modules/misc/wheelofnames_beta_api_client.go deleted file mode 100644 index 0b88720..0000000 --- a/internal/modules/misc/wheelofnames_beta_api_client.go +++ /dev/null @@ -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 -} diff --git a/internal/modules/misc/wheelofnames_beta_command.go b/internal/modules/misc/wheelofnames_beta_command.go deleted file mode 100644 index fe5bd53..0000000 --- a/internal/modules/misc/wheelofnames_beta_command.go +++ /dev/null @@ -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 , , ..." - 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 - }, - } -} diff --git a/internal/modules/misc/wheelofnames_command.go b/internal/modules/misc/wheelofnames_command.go new file mode 100644 index 0000000..7a822b0 --- /dev/null +++ b/internal/modules/misc/wheelofnames_command.go @@ -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 , , ..." + 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: ` + html.EscapeString(result) + `` +} + +func truncateWheelResultCaption(result string) string { + runes := []rune(result) + if len(runes) <= wheelResultCaptionMaxRunes { + return result + } + return string(runes[:wheelResultCaptionMaxRunes]) + "..." +} diff --git a/internal/modules/misc/wheelofnames_beta_drawing.go b/internal/modules/misc/wheelofnames_drawing.go similarity index 69% rename from internal/modules/misc/wheelofnames_beta_drawing.go rename to internal/modules/misc/wheelofnames_drawing.go index 02c28d1..ed961fb 100644 --- a/internal/modules/misc/wheelofnames_beta_drawing.go +++ b/internal/modules/misc/wheelofnames_drawing.go @@ -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) } diff --git a/internal/modules/misc/wheelofnames_beta_spin_profile.go b/internal/modules/misc/wheelofnames_spin_profile.go similarity index 61% rename from internal/modules/misc/wheelofnames_beta_spin_profile.go rename to internal/modules/misc/wheelofnames_spin_profile.go index efdaa86..d5b0d5b 100644 --- a/internal/modules/misc/wheelofnames_beta_spin_profile.go +++ b/internal/modules/misc/wheelofnames_spin_profile.go @@ -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 } diff --git a/internal/modules/stats/startup.go b/internal/modules/stats/startup.go index e076ca3..c90c342 100644 --- a/internal/modules/stats/startup.go +++ b/internal/modules/stats/startup.go @@ -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{ { diff --git a/internal/modules/stats/startup_test.go b/internal/modules/stats/startup_test.go index 3ff02ea..bb81954 100644 --- a/internal/modules/stats/startup_test.go +++ b/internal/modules/stats/startup_test.go @@ -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) + } +} diff --git a/telegram-commands.json b/telegram-commands.json index 83a2d5a..f78d6b2 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -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" }, {