From f67aa7cb7e6e0dd7bf85b4153101fbbf237fd81b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 9 Jul 2026 14:38:04 +0700 Subject: [PATCH] fix(misc): fall back wheelofnames to text reply --- .env.example | 5 +- docs/deploy-coolify-selfhosted.md | 23 +- go.mod | 3 +- go.sum | 2 - internal/modules/misc/handlers_test.go | 328 ++--------------- internal/modules/misc/misc.go | 4 +- .../modules/misc/wheelofnames_animation.go | 172 --------- .../modules/misc/wheelofnames_api_client.go | 24 +- internal/modules/misc/wheelofnames_command.go | 11 +- internal/modules/misc/wheelofnames_drawing.go | 344 ------------------ .../modules/misc/wheelofnames_spin_profile.go | 123 ------- internal/modules/stats/startup.go | 64 ++-- internal/modules/stats/startup_mongo_test.go | 112 ++++-- internal/modules/stats/startup_test.go | 145 ++++++-- internal/modules/stats/usage_store.go | 3 + internal/testutil/recording_bot.go | 33 +- telegram-commands.json | 2 +- 17 files changed, 342 insertions(+), 1056 deletions(-) delete mode 100644 internal/modules/misc/wheelofnames_animation.go delete mode 100644 internal/modules/misc/wheelofnames_drawing.go delete mode 100644 internal/modules/misc/wheelofnames_spin_profile.go diff --git a/.env.example b/.env.example index 53d53cd..a8378b4 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,9 @@ 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 /wheelofnames. Leave blank to use the -# built-in Go GIF renderer and fallback behavior. +# Optional /wheelofnames GIF renderer. Standard deployment: +# https://github.com/tiennm99/wheelofnames. Leave blank to fall back to text +# selection. WHEELOFNAMES_API_URL= # Bearer token matching the wheelofnames service API_TOKEN when URL is set. WHEELOFNAMES_API_TOKEN= diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 20655ee..283b0a5 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -46,10 +46,12 @@ overrides are not supported in runtime env; modules use coded defaults. ### Optional wheelofnames renderer -`/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`: +`/wheelofnames` uses a remote GIF renderer when `WHEELOFNAMES_API_URL` is set. +The standard renderer is a deployment of +[`tiennm99/wheelofnames`](https://github.com/tiennm99/wheelofnames), but any +service that implements the same `/api/gif` contract can be used. Set the URL +to the full GIF endpoint and set the token to the same value as the service +`API_TOKEN`: ```env WHEELOFNAMES_API_URL=http://wheelofnames:3000/api/gif @@ -66,9 +68,9 @@ 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. The GIF caption includes the result behind -Telegram spoiler formatting. +service is unset, unavailable, unauthorized, or returns a non-GIF response, +`/wheelofnames` falls back to the same plain text winner reply as `/random`. +Successful GIF replies include the result behind Telegram spoiler formatting. ## 1. MongoDB Atlas (M0) @@ -97,9 +99,10 @@ Telegram spoiler formatting. > `updatedAt` is a BSON Date. > > The `stats` collection uses queryable aggregate documents for command/user -> counts and creates indexes on startup. Deleted legacy command rows are retained -> with `deleted: true`; `/stats` queries filter those rows from visible results. -> A historical `system` collection may remain in MongoDB with completed migration +> counts and creates indexes on startup. Renamed command rows are merged into the +> current command name. Deleted legacy command rows are retained with +> `deleted: true`; `/stats` queries filter those rows from visible results. A +> historical `system` collection may remain in MongoDB with completed migration > records and can be reused if a future one-time startup migration is needed. ## 2. Coolify diff --git a/go.mod b/go.mod index ad7135c..032b258 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,6 @@ require ( github.com/go-telegram/bot v1.20.0 github.com/robfig/cron/v3 v3.0.1 go.mongodb.org/mongo-driver/v2 v2.7.0 - golang.org/x/image v0.41.0 - golang.org/x/text v0.37.0 ) require ( @@ -20,4 +18,5 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index fad5cd6..09ec254 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= -golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index f5d1358..70d4760 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -1,14 +1,9 @@ package misc import ( - "bytes" "context" "encoding/json" "fmt" - "image" - "image/gif" - "math" - "math/rand/v2" "net/http" "net/http/httptest" "slices" @@ -154,275 +149,6 @@ func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) { } } -func TestWheelOfNames_RenderGIFTiming(t *testing.T) { - data, err := renderWheelOfNamesGIF([]string{"Alice", "Bob"}, 0) - if err != nil { - t.Fatalf("renderWheelOfNamesGIF: %v", err) - } - decoded, err := gif.DecodeAll(bytes.NewReader(data)) - if err != nil { - t.Fatalf("DecodeAll: %v", err) - } - 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 < wheelSpinFrames && delay != wheelSpinDelay { - t.Fatalf("spin delay[%d] = %d, want %d", i, delay, wheelSpinDelay) - } - if i < wheelSpinFrames { - spinDelay += delay - continue - } - if delay != wheelHoldDelay { - t.Fatalf("hold delay[%d] = %d, want %d", i, delay, wheelHoldDelay) - } - holdDelay += delay - } - if spinDelay != wheelSpinDuration*100 { - t.Fatalf("spin delay total = %dcs, want %dcs", spinDelay, wheelSpinDuration*100) - } - if holdDelay != wheelHoldDuration*100 { - t.Fatalf("hold delay total = %dcs, want %dcs", holdDelay, wheelHoldDuration*100) - } - if totalDelay != wheelDuration*100 { - t.Fatalf("total delay = %dcs, want %dcs", totalDelay, wheelDuration*100) - } - 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[wheelSpinFrames], decoded.Image[wheelSpinFrames+1]) { - t.Fatalf("first celebration frame matches second celebration frame, want visible result burst") - } - 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) - } - } - if decoded.LoopCount != -1 { - t.Fatalf("loop count = %d, want -1", decoded.LoopCount) - } -} - -func equalPalettedFrames(a, b *image.Paletted) bool { - if !a.Rect.Eq(b.Rect) || a.Stride != b.Stride { - return false - } - return bytes.Equal(a.Pix, b.Pix) -} - -func TestWheelOfNames_CurrentOptionTracksPointer(t *testing.T) { - for winner := range []string{"Alice", "Bob", "Carol", "Dana"} { - rotation := finalWheelRotation(4, winner) - if got := currentWheelIndex(4, rotation); got != winner { - t.Fatalf("currentWheelIndex at final rotation = %d, want %d", got, winner) - } - } -} - -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 := 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 { - t.Fatalf("rotationAt(1) = %f, want final %f", got, profile.finalRotation) - } - } - } - } -} - -func TestWheelOfNames_SpinProfileVariesBetweenSpins(t *testing.T) { - rng := rand.New(rand.NewPCG(10, 20)) - first := newWheelSpinProfile(5, 2, rng) - second := newWheelSpinProfile(5, 2, rng) - if first.startRotation == second.startRotation && - first.finalRotation == second.finalRotation && - first.accelEnd == second.accelEnd && - first.decelSharpness == second.decelSharpness && - first.wobblePhase == second.wobblePhase { - t.Fatalf("spin profiles did not vary: %+v", first) - } -} - -func TestWheelOfNames_SpinProfileProgressIsMonotonic(t *testing.T) { - rng := rand.New(rand.NewPCG(30, 40)) - profile := newWheelSpinProfile(6, 4, rng) - prev := -1.0 - for i := 0; i <= 100; i++ { - progress := profile.progressAt(float64(i) / 100) - if progress < prev { - t.Fatalf("progress at step %d = %f, previous %f", i, progress, prev) - } - prev = progress - } -} - -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 != 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) - } - if got := img.ColorIndexAt(tipX-20, cy+12); got == 1 { - t.Fatalf("pointer widens inside wheel edge at color index %d", got) - } -} - -func TestWheelOfNames_FinalSliceRendersAtRightPointer(t *testing.T) { - options := []string{"Alice", "Bob", "Carol", "Dana"} - winner := 2 - 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 TestWheelOfNames_DrawsOptionLabelsInsideSlices(t *testing.T) { - options := []string{"Student", "Teacher", "Parent", "Staff"} - rotation := 0.0 - img := renderWheelFrame(options, 0, rotation, false) - segment := 2 * math.Pi / float64(len(options)) - angle := rotation + segment/2 - 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 TestWheelOfNames_RotatesOptionLabelsWithSlices(t *testing.T) { - options := []string{"Rotate", "Teacher", "Parent", "Staff"} - rotation := -3 * math.Pi / 4 - img := renderWheelFrame(options, 0, rotation, false) - - segment := 2 * math.Pi / float64(len(options)) - angle := rotation + segment/2 - 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, wheelInkColorIndex) - if !ok { - t.Fatalf("slice label dark pixels missing in %v", searchBounds) - } - if labelBounds.Dy() <= labelBounds.Dx() { - t.Fatalf("slice label bounds = %v, want taller than wide for rotated label", labelBounds) - } -} - -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 := wheelDisplayText(input, 64) - if got != want { - t.Fatalf("wheelDisplayText() = %q, want %q", got, want) - } - if strings.Contains(got, "?") { - t.Fatalf("wheelDisplayText() replaced Vietnamese with ?: %q", got) - } -} - -func TestWheelOfNames_DisplayTextNormalizesDecomposedVietnamese(t *testing.T) { - got := wheelDisplayText("tie\u0302\u0301ng Vie\u0323t", 32) - if got != "tieng Viet" { - t.Fatalf("wheelDisplayText() = %q, want %q", got, "tieng Viet") - } -} - -func countColorIndex(img *image.Paletted, bounds image.Rectangle, colorIndex byte) int { - bounds = bounds.Intersect(img.Bounds()) - count := 0 - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - if img.ColorIndexAt(x, y) == colorIndex { - count++ - } - } - } - return count -} - -func colorIndexBounds(img *image.Paletted, bounds image.Rectangle, colorIndex byte) (image.Rectangle, bool) { - bounds = bounds.Intersect(img.Bounds()) - minX, minY := bounds.Max.X, bounds.Max.Y - maxX, maxY := bounds.Min.X, bounds.Min.Y - found := false - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - if img.ColorIndexAt(x, y) != colorIndex { - continue - } - found = true - if x < minX { - minX = x - } - if y < minY { - minY = y - } - if x+1 > maxX { - maxX = x + 1 - } - if y+1 > maxY { - maxY = y + 1 - } - } - } - if !found { - return image.Rectangle{}, false - } - return image.Rect(minX, minY, maxX, maxY), true -} - -func TestWheelOfNames_SendsAnimationWithSpoilerCaption(t *testing.T) { - rb, _ := installMisc(t, 999) - 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 != `Result: Alice` { - t.Fatalf("caption = %q, want result spoiler", got) - } - 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) - } - if got := call.Form["width"]; got != "320" { - t.Fatalf("width = %q, want 320", got) - } - if got := call.Form["height"]; got != "320" { - t.Fatalf("height = %q, want 320", got) - } -} - func TestWheelOfNames_ResultCaptionEscapesHTML(t *testing.T) { got := wheelResultCaption(``) want := `Result: <Alice & Bob>` @@ -498,7 +224,7 @@ func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) { } } -func TestWheelOfNames_RemoteFailureFallsBackToLocalAnimation(t *testing.T) { +func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) { var calls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -515,27 +241,53 @@ func TestWheelOfNames_RemoteFailureFallsBackToLocalAnimation(t *testing.T) { t.Fatalf("remote calls = %d, want 1", calls) } call := rb.LastSent() - if call.Method != "sendAnimation" { - t.Fatalf("method = %q, want sendAnimation", call.Method) + if call.Method != "sendMessage" || call.Text() != "Alice" { + t.Fatalf("fallback call = %+v, want sendMessage Alice", call) } - if got := call.Form["caption"]; got != `Result: Alice` { - t.Fatalf("caption = %q, want result spoiler", got) +} + +func TestWheelOfNames_NotConfiguredFallsBackToRandomReply(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + call := rb.LastSent() + if call.Method != "sendMessage" || call.Text() != "Alice" { + t.Fatalf("fallback call = %+v, want sendMessage Alice", call) } - if got := call.Form["parse_mode"]; got != "HTML" { - t.Fatalf("parse_mode = %q, want HTML", got) +} + +func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/gif") + _, _ = w.Write([]byte("GIF89a-remote")) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + + rb, _ := installMisc(t, 999) + rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + calls := rb.Sent() + if len(calls) != 2 { + t.Fatalf("calls = %+v, want sendAnimation then sendMessage", calls) } - if got := call.Form["duration"]; got != "10" { - t.Fatalf("duration = %q, want local duration 10", got) + if calls[0].Method != "sendAnimation" { + t.Fatalf("first method = %q, want sendAnimation", calls[0].Method) } - if got := call.Form["width"]; got != "320" { - t.Fatalf("width = %q, want local width 320", got) - } - if got := call.Form["height"]; got != "320" { - t.Fatalf("height = %q, want local height 320", got) + if calls[1].Method != "sendMessage" || calls[1].Text() != "Alice" { + t.Fatalf("fallback call = %+v, want sendMessage Alice", calls[1]) } } func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/gif") + _, _ = w.Write([]byte("GIF89a-remote")) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + rb, _ := installMisc(t, 999) update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice") update.Message.MessageThreadID = 42 diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index aaad6bf..f6b1e81 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -1,7 +1,7 @@ // 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 GIF wheel -// picker), /the_answer (private easter egg), and small public disclaimer +// read), /random (public random picker), /wheelofnames (public wheel picker +// with optional GIF), /the_answer (private easter egg), and small public disclaimer // commands. package misc diff --git a/internal/modules/misc/wheelofnames_animation.go b/internal/modules/misc/wheelofnames_animation.go deleted file mode 100644 index 66c48bf..0000000 --- a/internal/modules/misc/wheelofnames_animation.go +++ /dev/null @@ -1,172 +0,0 @@ -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 index 889fd2b..0fdc682 100644 --- a/internal/modules/misc/wheelofnames_api_client.go +++ b/internal/modules/misc/wheelofnames_api_client.go @@ -13,11 +13,12 @@ import ( "os" "strings" "time" - - "github.com/tiennm99/miti99bot/internal/log" ) const ( + // The standard renderer is a deployment of + // https://github.com/tiennm99/wheelofnames. Operators may point this to any + // service that implements the same /api/gif contract. wheelOfNamesAPIURLEnv = "WHEELOFNAMES_API_URL" wheelOfNamesAPITokenEnv = "WHEELOFNAMES_API_TOKEN" @@ -163,25 +164,14 @@ func isWheelGIF(data []byte) bool { 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) + data, err := client.Render(ctx, options, winner) if err != nil { return wheelAnimation{}, err } return wheelAnimation{ Data: data, - Duration: wheelDuration, - Width: wheelSize, - Height: wheelSize, + Duration: wheelRemoteDuration, + Width: wheelRemoteSize, + Height: wheelRemoteSize, }, nil } diff --git a/internal/modules/misc/wheelofnames_command.go b/internal/modules/misc/wheelofnames_command.go index 7a822b0..d42ac57 100644 --- a/internal/modules/misc/wheelofnames_command.go +++ b/internal/modules/misc/wheelofnames_command.go @@ -3,6 +3,7 @@ package misc import ( "bytes" "context" + "errors" "html" "github.com/go-telegram/bot" @@ -23,7 +24,7 @@ func wheelOfNamesCommand() modules.Command { return modules.Command{ Name: "wheelofnames", Visibility: modules.VisibilityPublic, - Description: "Spin an animated wheel GIF for comma-separated options", + Description: "Pick one comma-separated option with wheel GIF when configured", Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { if update.Message == nil { return nil @@ -35,8 +36,10 @@ func wheelOfNamesCommand() modules.Command { 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])) + if !errors.Is(err, errWheelAPINotConfigured) { + log.Warn("wheelofnames remote render failed", "err", err) + } + return chathelper.Reply(ctx, b, update.Message, options[winner]) } _, err = b.SendAnimation(ctx, &bot.SendAnimationParams{ ChatID: update.Message.Chat.ID, @@ -53,7 +56,7 @@ func wheelOfNamesCommand() modules.Command { }) 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 chathelper.Reply(ctx, b, update.Message, options[winner]) } return nil }, diff --git a/internal/modules/misc/wheelofnames_drawing.go b/internal/modules/misc/wheelofnames_drawing.go deleted file mode 100644 index ed961fb..0000000 --- a/internal/modules/misc/wheelofnames_drawing.go +++ /dev/null @@ -1,344 +0,0 @@ -package misc - -import ( - "image" - "image/color" - "math" - "strings" - "unicode" - - "golang.org/x/image/font" - "golang.org/x/image/font/basicfont" - "golang.org/x/image/math/fixed" - "golang.org/x/text/unicode/norm" -) - -const ( - wheelSliceLabelRadius = 64 -) - -func drawWheelSliceLabels(img *image.Paletted, options []string, rotation float64) { - if len(options) == 0 { - return - } - cx, cy := wheelSize/2, wheelSize/2 - segment := 2 * math.Pi / float64(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(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 wheelSliceLabelLimit(optionCount int) int { - switch { - case optionCount <= 2: - return 14 - case optionCount <= 4: - return 11 - case optionCount <= 6: - return 8 - default: - return 6 - } -} - -func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) { - r2 := radius * radius - for y := cy - radius; y <= cy+radius; y++ { - for x := cx - radius; x <= cx+radius; x++ { - dx, dy := x-cx, y-cy - if dx*dx+dy*dy <= r2 { - img.SetColorIndex(x, y, colorIndex) - } - } - } -} - -func drawWheelDropShadow(img *image.Paletted, cx, cy int) { - rx := wheelRadius + 9 - ry := wheelRadius + 5 - shadowCY := cy + 8 - limit := rx * rx * ry * ry - for y := shadowCY - ry; y <= shadowCY+ry; y++ { - for x := cx - rx; x <= cx+rx; x++ { - dx := x - cx - dy := y - shadowCY - if dx*dx*ry*ry+dy*dy*rx*rx <= limit { - setWheelPixel(img, x, y, wheelShadowColorIndex) - } - } - } -} - -func drawWheelLighting(img *image.Paletted, cx, cy int) { - outer := wheelRadius * wheelRadius - edgeStart := wheelRadius - 13 - edge := edgeStart * edgeStart - 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 > wheelRadius/3 { - img.SetColorIndex(x, y, wheelBevelColorIndex) - } - if dx+dy < -wheelRadius { - img.SetColorIndex(x, y, wheelHighlightColorIndex) - } - } - } - - drawHighlightOval(img, cx-30, cy-50, 44, 18) -} - -func drawHighlightOval(img *image.Paletted, cx, cy, rx, ry int) { - limit := rx * rx * ry * ry - for y := cy - ry; y <= cy+ry; y++ { - for x := cx - rx; x <= cx+rx; x++ { - dx := x - cx - dy := y - cy - if dx*dx*ry*ry+dy*dy*rx*rx <= limit { - setWheelPixel(img, x, y, wheelHighlightColorIndex) - } - } - } -} - -func drawWheelRim(img *image.Paletted, cx, cy int) { - 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) { - outer := radius * radius - innerRadius := radius - thickness - inner := innerRadius * innerRadius - for y := cy - radius; y <= cy+radius; y++ { - for x := cx - radius; x <= cx+radius; x++ { - dx, dy := x-cx, y-cy - d2 := dx*dx + dy*dy - if d2 <= outer && d2 >= inner { - img.SetColorIndex(x, y, colorIndex) - } - } - } -} - -func drawPointer(img *image.Paletted, tipX, cy int, fillColorIndex byte) { - for xOffset := 0; xOffset < 34; xOffset++ { - half := xOffset / 2 - x := tipX + xOffset - for y := cy - half; y <= cy+half; y++ { - setWheelPixel(img, x, y, wheelInkColorIndex) - } - } - for xOffset := 3; xOffset < 30; xOffset++ { - half := xOffset/2 - 2 - if half < 0 { - continue - } - x := tipX + xOffset - for y := cy - half; y <= cy+half; y++ { - setWheelPixel(img, x, y, fillColorIndex) - } - } -} - -func drawWinnerCelebration(img *image.Paletted, step int) { - if step < 0 { - return - } - - cx, cy := wheelSize/2, wheelSize/2 - phase := step % wheelCelebrateFrames - ringRadius := 34 + phase*5 - 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(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 := wheelSliceColorIndexes[(i+phase)%len(wheelSliceColorIndexes)] - if i%5 == 0 { - colorIndex = wheelSparkColorIndex - } - drawPalettedLine(img, x1, y1, x2, y2, colorIndex) - drawSpark(img, x2, y2, colorIndex) - } -} - -func drawStatusBand(img *image.Paletted, label, value string) { - for y := 235; y < 286; y++ { - for x := 31; x < wheelSize-25; x++ { - img.SetColorIndex(x, y, wheelShadowColorIndex) - } - } - for y := 230; y < 282; y++ { - for x := 28; x < wheelSize-28; x++ { - img.SetColorIndex(x, y, wheelPaperColorIndex) - } - } - for x := 28; x < wheelSize-28; x++ { - img.SetColorIndex(x, 230, wheelHighlightColorIndex) - img.SetColorIndex(x, 281, wheelBevelColorIndex) - } - drawCenteredText(img, label, 250, wheelInkColorIndex) - drawCenteredText(img, value, 270, wheelInkColorIndex) -} - -func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) { - drawCenteredTextAt(img, text, wheelSize/2, baselineY, colorIndex) -} - -func drawCenteredTextAt(img *image.Paletted, text string, centerX, baselineY int, colorIndex byte) { - face := basicfont.Face7x13 - width := font.MeasureString(face, text).Ceil() - x := centerX - width/2 - drawer := font.Drawer{ - Dst: img, - Src: image.NewUniform(wheelPalette[colorIndex]), - Face: face, - Dot: fixed.P(x, baselineY), - } - drawer.DrawString(text) -} - -func drawRotatedCenteredTextAt(img *image.Paletted, text string, centerX, centerY int, angle float64, colorIndex byte) { - face := basicfont.Face7x13 - padding := 2 - metrics := face.Metrics() - textWidth := font.MeasureString(face, text).Ceil() - textHeight := metrics.Height.Ceil() - mask := image.NewAlpha(image.Rect(0, 0, textWidth+padding*2, textHeight+padding*2)) - drawer := font.Drawer{ - Dst: mask, - Src: image.NewUniform(color.Alpha{A: 255}), - Face: face, - Dot: fixed.P(padding, padding+metrics.Ascent.Ceil()), - } - drawer.DrawString(text) - - sourceCenterX := float64(mask.Bounds().Dx()-1) / 2 - sourceCenterY := float64(mask.Bounds().Dy()-1) / 2 - sin, cos := math.Sin(angle), math.Cos(angle) - for y := mask.Bounds().Min.Y; y < mask.Bounds().Max.Y; y++ { - for x := mask.Bounds().Min.X; x < mask.Bounds().Max.X; x++ { - if mask.AlphaAt(x, y).A == 0 { - continue - } - localX := float64(x) - sourceCenterX - localY := float64(y) - sourceCenterY - targetX := centerX + int(math.Round(localX*cos-localY*sin)) - targetY := centerY + int(math.Round(localX*sin+localY*cos)) - setWheelPixel(img, targetX, targetY, colorIndex) - } - } -} - -func wheelDisplayText(s string, limit int) string { - var b strings.Builder - count := 0 - for _, r := range norm.NFD.String(s) { - if unicode.Is(unicode.Mn, r) { - continue - } - if count >= limit { - break - } - r = wheelASCIIRune(r) - if unicode.IsControl(r) { - b.WriteByte('?') - count++ - continue - } - if r < 32 || r > 126 { - b.WriteByte('?') - count++ - continue - } - b.WriteRune(r) - count++ - } - out := strings.TrimSpace(b.String()) - if out == "" { - return "winner" - } - return out -} - -func wheelASCIIRune(r rune) rune { - switch r { - case 'đ': - return 'd' - case 'Đ': - return 'D' - default: - return r - } -} - -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 { - setWheelPixel(img, x, y, colorIndex) - } - } - } -} - -func drawPalettedLine(img *image.Paletted, x0, y0, x1, y1 int, colorIndex byte) { - dx := absInt(x1 - x0) - sx := -1 - if x0 < x1 { - sx = 1 - } - dy := -absInt(y1 - y0) - sy := -1 - if y0 < y1 { - sy = 1 - } - err := dx + dy - for { - setWheelPixel(img, x0, y0, colorIndex) - if x0 == x1 && y0 == y1 { - return - } - e2 := 2 * err - if e2 >= dy { - err += dy - x0 += sx - } - if e2 <= dx { - err += dx - y0 += sy - } - } -} - -func setWheelPixel(img *image.Paletted, x, y int, colorIndex byte) { - if image.Pt(x, y).In(img.Rect) { - img.SetColorIndex(x, y, colorIndex) - } -} - -func absInt(v int) int { - if v < 0 { - return -v - } - return v -} diff --git a/internal/modules/misc/wheelofnames_spin_profile.go b/internal/modules/misc/wheelofnames_spin_profile.go deleted file mode 100644 index d5b0d5b..0000000 --- a/internal/modules/misc/wheelofnames_spin_profile.go +++ /dev/null @@ -1,123 +0,0 @@ -package misc - -import ( - "math" - "math/rand/v2" -) - -const ( - wheelMinRevolutions = 7 - wheelRandomRevolutionRange = 5 - wheelMaxLandingOffset = 0.34 -) - -type wheelSpinProfile struct { - startRotation float64 - finalRotation float64 - accelEnd float64 - decelSharpness float64 - wobbleAmplitude float64 - wobbleCycles float64 - wobblePhase float64 -} - -func newWheelSpinProfile(optionCount, winner int, rng *rand.Rand) wheelSpinProfile { - segment := 2 * math.Pi / float64(optionCount) - landingOffset := (wheelRandFloat64(rng)*2 - 1) * wheelMaxLandingOffset - finalRotation := finalWheelRotationWithOffset(optionCount, winner, landingOffset) - 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 + 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 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 wheelSpinProfile) statusAt(t float64) string { - switch { - case t < p.accelEnd: - return "BUILDING SPEED" - case t < 0.82: - return "DECELERATING" - case t < 0.96: - return "LOCKING IN" - default: - return "LAST TICK" - } -} - -func (p wheelSpinProfile) progressAt(t float64) float64 { - t = clampWheelProgress(t) - if t == 0 || t == 1 { - return t - } - - accelEnd := p.accelEnd - if accelEnd <= 0 || accelEnd >= 1 { - remaining := 1 - t - return 1 - remaining*remaining*remaining - } - - accelDistance := accelEnd * 0.72 - if t < accelEnd { - u := t / accelEnd - return accelDistance * u * u - } - - sharpness := p.decelSharpness - if sharpness <= 0 { - sharpness = 4 - } - u := (t - accelEnd) / (1 - accelEnd) - decelProgress := (1 - math.Exp(-sharpness*u)) / (1 - math.Exp(-sharpness)) - return accelDistance + (1-accelDistance)*decelProgress -} - -func (p wheelSpinProfile) wobbleAt(t float64) float64 { - if t <= p.accelEnd || t >= 1 { - return 0 - } - u := (t - p.accelEnd) / (1 - p.accelEnd) - envelope := math.Sin(u*math.Pi) * math.Pow(1-u, 1.4) - return p.wobbleAmplitude * envelope * math.Sin(p.wobblePhase+u*p.wobbleCycles*2*math.Pi) -} - -func clampWheelProgress(t float64) float64 { - switch { - case t < 0: - return 0 - case t > 1: - return 1 - default: - return t - } -} - -func wheelRandFloat64(rng *rand.Rand) float64 { - if rng != nil { - return rng.Float64() - } - return rand.Float64() -} - -func wheelRandIntN(rng *rand.Rand, n int) int { - if n <= 0 { - return 0 - } - if rng != nil { - return rng.IntN(n) - } - return rand.IntN(n) -} diff --git a/internal/modules/stats/startup.go b/internal/modules/stats/startup.go index c90c342..90137ac 100644 --- a/internal/modules/stats/startup.go +++ b/internal/modules/stats/startup.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "time" @@ -24,8 +25,9 @@ const ( oldLolNextWeekCommand = "lol_nextweek" newLolNextWeekCommand = "lol_next_week" - deleteLegacyWheelOfNamesStatsKey = "stats:command-delete:wheelofnamesbeta" - deletedLegacyWheelOfNamesCommand = "wheelofnamesbeta" + renameWheelOfNamesBetaStatsKey = "stats:command-rename:wheelofnamesbeta-to-wheelofnames" + legacyWheelOfNamesBetaCommand = "wheelofnamesbeta" + currentWheelOfNamesCommand = "wheelofnames" ) // InitStore performs stats collection startup maintenance. It is safe to call @@ -40,7 +42,7 @@ 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 { + if err := migrateCommandRename(ctx, statsColl, systemColl, legacyWheelOfNamesBetaCommand, currentWheelOfNamesCommand, renameWheelOfNamesBetaStatsKey); err != nil { return err } return nil @@ -78,10 +80,15 @@ func migrateCommandRename(ctx context.Context, statsColl, systemColl storage.Col if err != nil && !errors.Is(err, storage.ErrNotFound) { return fmt.Errorf("stats command rename get target %s: %w", targetKey, err) } - missingTarget := errors.Is(err, storage.ErrNotFound) - if missingTarget { + if errors.Is(err, storage.ErrNotFound) { target = usageEntry{} } + if slices.Contains(target.MergedFrom, key) { + if err := docs.Delete(ctx, key); err != nil { + return fmt.Errorf("stats command rename delete already-merged old %s: %w", key, err) + } + continue + } target.Cmd = newCmd target.UserID = entry.UserID @@ -91,12 +98,8 @@ func migrateCommandRename(ctx context.Context, statsColl, systemColl storage.Col target.Username = entry.Username } target.N += entry.N - if missingTarget { - target.Deleted = entry.Deleted - } - if !entry.Deleted { - target.Deleted = false - } + target.Deleted = false + target.MergedFrom = append(target.MergedFrom, key) if err := docs.Put(ctx, targetKey, target); err != nil { return fmt.Errorf("stats command rename put target %s: %w", targetKey, err) } @@ -106,6 +109,10 @@ func migrateCommandRename(ctx context.Context, statsColl, systemColl storage.Col moved += entry.N } + if err := clearCommandMergeMarkers(ctx, docs, newCmd); err != nil { + return err + } + now := time.Now().UTC().UnixMilli() if err := state.Put(ctx, markerKey, systemstate.Record{ Kind: "migration", @@ -120,49 +127,26 @@ 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) +func clearCommandMergeMarkers(ctx context.Context, docs storage.DocStore[usageEntry], cmd string) error { keys, err := docs.List(ctx, cmd) if err != nil { - return fmt.Errorf("stats command delete list %s: %w", cmd, err) + return fmt.Errorf("stats command rename cleanup 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) + return fmt.Errorf("stats command rename cleanup get %s: %w", key, err) } - if entry.Cmd != cmd || entry.Deleted { + if entry.Cmd != cmd || len(entry.MergedFrom) == 0 { continue } - entry.Deleted = true + entry.MergedFrom = nil if err := docs.Put(ctx, key, entry); err != nil { - return fmt.Errorf("stats command delete put %s: %w", key, err) + return fmt.Errorf("stats command rename cleanup 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 } diff --git a/internal/modules/stats/startup_mongo_test.go b/internal/modules/stats/startup_mongo_test.go index b9de03a..5eb2c82 100644 --- a/internal/modules/stats/startup_mongo_test.go +++ b/internal/modules/stats/startup_mongo_test.go @@ -2,8 +2,10 @@ package stats import ( "context" + "errors" "fmt" "os" + "strings" "testing" "time" @@ -12,30 +14,7 @@ import ( ) func TestInitStore_MongoCreatesIndexes(t *testing.T) { - uri := os.Getenv("MONGODB_TEST_URL") - if uri == "" { - t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test") - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - client, err := storage.NewMongoClient(ctx, uri) - if err != nil { - t.Fatalf("NewMongoClient: %v", err) - } - dbName := fmt.Sprintf("miti99bot_stats_test_%d", time.Now().UnixNano()) - db := client.Database(dbName) - defer func() { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = db.Drop(cleanupCtx) - _ = client.Disconnect(cleanupCtx) - }() - - provider := storage.NewMongoProvider(db) - statsColl := provider.Collection("stats") - systemColl := provider.Collection(systemstate.CollectionName) + ctx, statsColl, systemColl := setupMongoStatsTest(t) rawStatsColl, ok := storage.MongoCollection(statsColl) if !ok { @@ -74,3 +53,88 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) { } } } + +func TestInitStore_MongoMergesLegacyWheelOfNamesBetaStats(t *testing.T) { + ctx, statsColl, systemColl := setupMongoStatsTest(t) + docs := storage.Typed[usageEntry](statsColl) + + seeds := map[string]usageEntry{ + usageKey(legacyWheelOfNamesBetaCommand, 0): {Cmd: legacyWheelOfNamesBetaCommand, N: 2}, + usageKey(legacyWheelOfNamesBetaCommand, 7): { + Cmd: legacyWheelOfNamesBetaCommand, + UserID: 7, + Username: "alice", + N: 3, + Deleted: true, + }, + usageKey(currentWheelOfNamesCommand, 7): { + Cmd: currentWheelOfNamesCommand, + 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) + } + + anon, _, err := docs.Get(ctx, usageKey(currentWheelOfNamesCommand, 0)) + if err != nil { + t.Fatalf("merged anonymous wheelofnames stats: %v", err) + } + if anon.N != 2 || anon.Deleted || len(anon.MergedFrom) != 0 { + t.Fatalf("merged anonymous stats = %+v, want visible count 2", anon) + } + alice, _, err := docs.Get(ctx, usageKey(currentWheelOfNamesCommand, 7)) + if err != nil { + t.Fatalf("merged alice wheelofnames stats: %v", err) + } + if alice.N != 8 || alice.Deleted || len(alice.MergedFrom) != 0 { + t.Fatalf("merged alice stats = %+v, want visible count 8", alice) + } + for _, key := range []string{usageKey(legacyWheelOfNamesBetaCommand, 0), usageKey(legacyWheelOfNamesBetaCommand, 7)} { + if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { + t.Fatalf("legacy key %s err = %v, want ErrNotFound", key, err) + } + } + if got := renderStats(ctx, newCounter(statsColl), ""); !strings.Contains(got, "/wheelofnames: 10") || strings.Contains(got, "wheelofnamesbeta") { + t.Fatalf("top commands after Mongo migration = %q, want merged visible /wheelofnames count 10 only", got) + } +} + +func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection, storage.Collection) { + t.Helper() + + uri := os.Getenv("MONGODB_TEST_URL") + if uri == "" { + t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + client, err := storage.NewMongoClient(ctx, uri) + if err != nil { + t.Fatalf("NewMongoClient: %v", err) + } + dbName := fmt.Sprintf("miti99bot_stats_test_%d", time.Now().UnixNano()) + db := client.Database(dbName) + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.Drop(cleanupCtx) + _ = client.Disconnect(cleanupCtx) + }) + + provider := storage.NewMongoProvider(db) + return ctx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName) +} diff --git a/internal/modules/stats/startup_test.go b/internal/modules/stats/startup_test.go index bb81954..39a42e2 100644 --- a/internal/modules/stats/startup_test.go +++ b/internal/modules/stats/startup_test.go @@ -3,6 +3,7 @@ package stats import ( "context" "errors" + "strings" "testing" "github.com/tiennm99/miti99bot/internal/storage" @@ -75,7 +76,7 @@ func TestInitStore_RenamesLolNextWeekStatsOnce(t *testing.T) { } } -func TestInitStore_MarksLegacyWheelOfNamesStatsDeletedOnce(t *testing.T) { +func TestInitStore_MergesLegacyWheelOfNamesBetaStatsOnce(t *testing.T) { ctx := context.Background() provider := storage.NewMemoryProvider() statsColl := provider.Collection("stats") @@ -83,15 +84,22 @@ func TestInitStore_MarksLegacyWheelOfNamesStatsDeletedOnce(t *testing.T) { docs := storage.Typed[usageEntry](statsColl) seeds := map[string]usageEntry{ - usageKey(deletedLegacyWheelOfNamesCommand, 0): {Cmd: deletedLegacyWheelOfNamesCommand, N: 2}, - usageKey(deletedLegacyWheelOfNamesCommand, 7): { - Cmd: deletedLegacyWheelOfNamesCommand, + usageKey(legacyWheelOfNamesBetaCommand, 0): {Cmd: legacyWheelOfNamesBetaCommand, N: 2}, + usageKey(legacyWheelOfNamesBetaCommand, 7): { + Cmd: legacyWheelOfNamesBetaCommand, UserID: 7, Username: "alice", N: 3, }, - usageKey("wheelofnames", 7): { - Cmd: "wheelofnames", + usageKey(legacyWheelOfNamesBetaCommand, 8): { + Cmd: legacyWheelOfNamesBetaCommand, + UserID: 8, + Username: "bob", + N: 4, + Deleted: true, + }, + usageKey(currentWheelOfNamesCommand, 7): { + Cmd: currentWheelOfNamesCommand, UserID: 7, Username: "alice", N: 5, @@ -110,29 +118,120 @@ func TestInitStore_MarksLegacyWheelOfNamesStatsDeletedOnce(t *testing.T) { 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)) + anon, _, err := docs.Get(ctx, usageKey(currentWheelOfNamesCommand, 0)) if err != nil { - t.Fatalf("active wheelofnames stats: %v", err) + t.Fatalf("merged anonymous wheelofnames stats: %v", err) } - if active.Deleted { - t.Fatalf("active wheelofnames stats = %+v, want not deleted", active) + if anon.Cmd != currentWheelOfNamesCommand || anon.N != 2 || anon.UserID != 0 || anon.Deleted || len(anon.MergedFrom) != 0 { + t.Fatalf("merged anonymous stats = %+v, want visible count 2", anon) } - rec, ok, err := systemstate.New(systemColl).Get(ctx, deleteLegacyWheelOfNamesStatsKey) + alice, _, err := docs.Get(ctx, usageKey(currentWheelOfNamesCommand, 7)) + if err != nil { + t.Fatalf("merged alice wheelofnames stats: %v", err) + } + if alice.Cmd != currentWheelOfNamesCommand || alice.UserID != 7 || alice.Username != "alice" || alice.N != 8 || alice.Deleted || len(alice.MergedFrom) != 0 { + t.Fatalf("merged alice stats = %+v, want visible count 8", alice) + } + + bob, _, err := docs.Get(ctx, usageKey(currentWheelOfNamesCommand, 8)) + if err != nil { + t.Fatalf("merged bob wheelofnames stats: %v", err) + } + if bob.Cmd != currentWheelOfNamesCommand || bob.UserID != 8 || bob.Username != "bob" || bob.N != 4 || bob.Deleted || len(bob.MergedFrom) != 0 { + t.Fatalf("merged bob stats = %+v, want visible count 4", bob) + } + + renderedTopCommands := renderStats(ctx, newCounter(statsColl), "") + if !strings.Contains(renderedTopCommands, "/wheelofnames: 14") || strings.Contains(renderedTopCommands, "wheelofnamesbeta") { + t.Fatalf("top commands after migration = %q, want merged visible /wheelofnames count 14 only", renderedTopCommands) + } + renderedCommandUsers := renderStats(ctx, newCounter(statsColl), "cmd wheelofnames") + if !strings.Contains(renderedCommandUsers, "@alice: 8") || !strings.Contains(renderedCommandUsers, "@bob: 4") { + t.Fatalf("wheelofnames users after migration = %q, want merged users", renderedCommandUsers) + } + + for _, key := range []string{ + usageKey(legacyWheelOfNamesBetaCommand, 0), + usageKey(legacyWheelOfNamesBetaCommand, 7), + usageKey(legacyWheelOfNamesBetaCommand, 8), + } { + if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { + t.Fatalf("legacy key %s err = %v, want ErrNotFound", key, err) + } + } + + rec, ok, err := systemstate.New(systemColl).Get(ctx, renameWheelOfNamesBetaStatsKey) 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) + if !ok || rec.Status != "complete" || rec.Count != 9 { + t.Fatalf("migration marker = %+v ok=%v, want complete count 9", rec, ok) + } +} + +func TestInitStore_RetriesPartiallyMergedWheelOfNamesBetaStats(t *testing.T) { + ctx := context.Background() + provider := storage.NewMemoryProvider() + statsColl := provider.Collection("stats") + systemColl := provider.Collection(systemstate.CollectionName) + docs := storage.Typed[usageEntry](statsColl) + + alreadyMergedKey := usageKey(legacyWheelOfNamesBetaCommand, 7) + seeds := map[string]usageEntry{ + alreadyMergedKey: { + Cmd: legacyWheelOfNamesBetaCommand, + UserID: 7, + Username: "alice", + N: 3, + }, + usageKey(currentWheelOfNamesCommand, 7): { + Cmd: currentWheelOfNamesCommand, + UserID: 7, + Username: "alice", + N: 8, + MergedFrom: []string{alreadyMergedKey}, + }, + usageKey(legacyWheelOfNamesBetaCommand, 9): { + Cmd: legacyWheelOfNamesBetaCommand, + UserID: 9, + Username: "carol", + N: 6, + }, + } + 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) + } + + alice, _, err := docs.Get(ctx, usageKey(currentWheelOfNamesCommand, 7)) + if err != nil { + t.Fatalf("alice wheelofnames stats: %v", err) + } + if alice.N != 8 || len(alice.MergedFrom) != 0 { + t.Fatalf("alice stats = %+v, want count still 8 with cleanup marker removed", alice) + } + carol, _, err := docs.Get(ctx, usageKey(currentWheelOfNamesCommand, 9)) + if err != nil { + t.Fatalf("carol wheelofnames stats: %v", err) + } + if carol.N != 6 || carol.Deleted || len(carol.MergedFrom) != 0 { + t.Fatalf("carol stats = %+v, want visible count 6", carol) + } + for _, key := range []string{alreadyMergedKey, usageKey(legacyWheelOfNamesBetaCommand, 9)} { + if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { + t.Fatalf("legacy key %s err = %v, want ErrNotFound", key, err) + } + } + if got := renderStats(ctx, newCounter(statsColl), "cmd wheelofnames"); !strings.Contains(got, "@alice: 8") || !strings.Contains(got, "@carol: 6") { + t.Fatalf("wheelofnames users after retry = %q, want no duplicate alice count and visible carol count", got) } } diff --git a/internal/modules/stats/usage_store.go b/internal/modules/stats/usage_store.go index 9d81f4d..4273512 100644 --- a/internal/modules/stats/usage_store.go +++ b/internal/modules/stats/usage_store.go @@ -26,6 +26,9 @@ type usageEntry struct { Username string `json:"user,omitempty" bson:"user,omitempty"` N int64 `json:"n" bson:"n"` Deleted bool `json:"deleted,omitempty" bson:"deleted,omitempty"` + // MergedFrom is temporary startup-migration bookkeeping. Completed + // migrations clear it so normal stats documents stay compact. + MergedFrom []string `json:"mergedFrom,omitempty" bson:"mergedFrom,omitempty"` } type usageUser struct { diff --git a/internal/testutil/recording_bot.go b/internal/testutil/recording_bot.go index 2b3cd05..ce2fe83 100644 --- a/internal/testutil/recording_bot.go +++ b/internal/testutil/recording_bot.go @@ -36,8 +36,14 @@ type RecordingBot struct { Bot *bot.Bot Server *httptest.Server - mu sync.Mutex - calls []SentCall + mu sync.Mutex + calls []SentCall + failures map[string]failureResponse +} + +type failureResponse struct { + status int + body string } // NewRecordingBot constructs a recording bot. The bot uses a synthetic token @@ -87,6 +93,23 @@ func (rb *RecordingBot) Reset() { rb.mu.Unlock() } +// FailMethod makes the recording server return a Telegram API error for a +// specific method while still recording the attempted call. +func (rb *RecordingBot) FailMethod(method string, status int, body string) { + rb.mu.Lock() + defer rb.mu.Unlock() + if rb.failures == nil { + rb.failures = map[string]failureResponse{} + } + if status == 0 { + status = http.StatusInternalServerError + } + if body == "" { + body = `{"ok":false,"description":"forced test failure"}` + } + rb.failures[method] = failureResponse{status: status, body: body} +} + // handle is the httptest server's request handler. Path shape is // "/bot/" per the go-telegram/bot URL builder. We extract the // method, parse the multipart form, record, and respond with a minimal-ok @@ -109,9 +132,15 @@ func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) { rb.mu.Lock() rb.calls = append(rb.calls, SentCall{Method: method, Form: form}) + failure, shouldFail := rb.failures[method] rb.mu.Unlock() w.Header().Set("Content-Type", "application/json") + if shouldFail { + w.WriteHeader(failure.status) + _, _ = w.Write([]byte(failure.body)) + return + } _, _ = w.Write([]byte(okResponseFor(method))) } diff --git a/telegram-commands.json b/telegram-commands.json index f78d6b2..369bf4e 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -14,7 +14,7 @@ }, { "command": "wheelofnames", - "description": "Send an animated wheel GIF for comma-separated options" + "description": "Pick one option with wheel GIF when configured" }, { "command": "trongtruonghop",