mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-30 16:22:56 +00:00
fix(misc): fall back wheelofnames to text reply
This commit is contained in:
+3
-2
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"><Alice & Bob></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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user