fix(misc): fall back wheelofnames to text reply

This commit is contained in:
2026-07-09 14:38:04 +07:00
parent 0bd7d71513
commit f67aa7cb7e
17 changed files with 342 additions and 1056 deletions
+3 -2
View File
@@ -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=
+13 -10
View File
@@ -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=<same value as wheelofnames API_TOKEN>
The bot sends outbound HTTP only; no public bot ingress is required. Remote
renders use `512px`, `20fps`, and `7` seconds total by default. If the remote
service is unset, unavailable, unauthorized, or returns a non-GIF response, the
bot falls back to the local renderer. 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
+1 -2
View File
@@ -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
)
-2
View File
@@ -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=
+40 -288
View File
@@ -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: <span class="tg-spoiler">Alice</span>` {
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(`<Alice & Bob>`)
want := `Result: <span class="tg-spoiler">&lt;Alice &amp; Bob&gt;</span>`
@@ -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: <span class="tg-spoiler">Alice</span>` {
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
+2 -2
View File
@@ -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
@@ -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
}
@@ -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
}
@@ -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
},
@@ -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
}
@@ -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)
}
+24 -40
View File
@@ -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
}
+88 -24
View File
@@ -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)
}
+122 -23
View File
@@ -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)
}
}
+3
View File
@@ -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 {
+31 -2
View File
@@ -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<token>/<method>" 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)))
}
+1 -1
View File
@@ -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",