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