feat(misc): add wheelofnamesbeta render API

This commit is contained in:
2026-07-07 00:12:58 +07:00
parent a7642e45f6
commit 044eab1e57
12 changed files with 1063 additions and 5 deletions
+6
View File
@@ -29,6 +29,12 @@ 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
# built-in Go GIF renderer and fallback behavior.
WHEELOFNAMES_API_URL=
# Bearer token matching the wheelofnames service API_TOKEN when URL is set.
WHEELOFNAMES_API_TOKEN=
# ====================== Leave UNSET on self-host ==================
# Defaults are correct for self-host:
# KV_PROVIDER — auto-selects mongodb because MONGO_URL is set
+2
View File
@@ -14,6 +14,8 @@ services:
MODULES: ${MODULES} # CSV; empty = all modules
OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands
ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids
WHEELOFNAMES_API_URL: ${WHEELOFNAMES_API_URL:-} # Optional full /api/gif endpoint
WHEELOFNAMES_API_TOKEN: ${WHEELOFNAMES_API_TOKEN:-} # Optional bearer token for that service
# SOURCE_COMMIT is intentionally not declared here. Coolify provides it
# at runtime via its generated env file; declaring it here with Compose
# interpolation can override the runtime value with an empty string.
+28
View File
@@ -34,6 +34,8 @@ 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_TOKEN` | optional | bearer token matching the wheelofnames service `API_TOKEN` |
**Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`,
`TELEGRAM_WEBHOOK_SECRET`, and `GOLD_VNAPP_API_KEY`. Stock, coin, and gold URL
@@ -42,6 +44,32 @@ overrides are not supported in runtime env; modules use coded defaults.
> Cron runs in-process (`internal/cron`) — there is no `/cron` HTTP route and no
> `CRON_SHARED_SECRET`. The scheduler is the sole trigger; nothing inbound.
### Optional wheelofnames renderer
`/wheelofnamesbeta` 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`:
```env
WHEELOFNAMES_API_URL=http://wheelofnames:3000/api/gif
WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>
```
Use a public HTTPS URL instead when the bot cannot reach the service on a
private Coolify/Docker network:
```env
WHEELOFNAMES_API_URL=https://wheelofnames.example.com/api/gif
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.
## 1. MongoDB Atlas (M0)
1. Create a free **M0** cluster (512 MB — ample for the tiny paper-trading KV).
+99
View File
@@ -3,10 +3,14 @@ package misc
import (
"bytes"
"context"
"encoding/json"
"image"
"image/gif"
"math"
"math/rand/v2"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
@@ -513,6 +517,101 @@ func TestWheelOfNamesBeta_SendsAnimationWithoutSpoilingCaption(t *testing.T) {
}
}
func TestWheelOfNamesBeta_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got wheelBetaAPIRequest
var gotAuthorization string
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/api/gif" {
t.Errorf("path = %q, want /api/gif", r.URL.Path)
}
gotAuthorization = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("Decode request body: %v", err)
}
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesBetaAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesBetaAPITokenEnv, "remote-token")
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice, Bob, Carol"))
if calls != 1 {
t.Fatalf("remote calls = %d, want 1", calls)
}
if gotAuthorization != "Bearer remote-token" {
t.Fatalf("Authorization = %q, want bearer token", gotAuthorization)
}
if !slices.Equal(got.Options, []string{"Alice", "Bob", "Carol"}) {
t.Fatalf("options = %#v, want parsed options", got.Options)
}
if got.WinnerIndex < 0 || got.WinnerIndex >= len(got.Options) {
t.Fatalf("winnerIndex = %d, want in range", got.WinnerIndex)
}
assertWheelBetaRemoteDefaults(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)
}
if strings.Contains(call.Form["caption"], got.Options[got.WinnerIndex]) {
t.Fatalf("caption spoils winner: %q", call.Form["caption"])
}
if got := call.Form["duration"]; got != "7" {
t.Fatalf("duration = %q, want 7", got)
}
if got := call.Form["width"]; got != "512" {
t.Fatalf("width = %q, want 512", got)
}
if got := call.Form["height"]; got != "512" {
t.Fatalf("height = %q, want 512", got)
}
}
func TestWheelOfNamesBeta_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")
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice"))
if calls != 1 {
t.Fatalf("remote calls = %d, want 1", calls)
}
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 strings.Contains(call.Form["caption"], "Alice") {
t.Fatalf("caption spoils winner: %q", call.Form["caption"])
}
if got := call.Form["duration"]; got != "10" {
t.Fatalf("duration = %q, want local duration 10", got)
}
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)
}
}
func TestWheelOfNamesBeta_ForwardsMessageThreadID(t *testing.T) {
rb, _ := installMisc(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnamesbeta Alice")
@@ -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 (
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
}
@@ -0,0 +1,184 @@
package misc
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
func TestWheelBetaAPIClient_RenderValidRequest(t *testing.T) {
var got wheelBetaAPIRequest
var gotAccept string
var gotAuthorization string
var gotContentType string
var gotMethod string
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotAccept = r.Header.Get("Accept")
gotAuthorization = r.Header.Get("Authorization")
gotContentType = r.Header.Get("Content-Type")
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("Decode request body: %v", err)
}
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
client := wheelBetaAPIClient{
HTTP: server.Client(),
URL: server.URL + "/api/gif",
Token: "secret-token",
}
data, err := client.Render(context.Background(), []string{"alice", "bob", "carol"}, 1)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !bytes.Equal(data, []byte("GIF89a-remote")) {
t.Fatalf("data = %q, want remote GIF bytes", data)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if gotPath != "/api/gif" {
t.Fatalf("path = %q, want /api/gif", gotPath)
}
if gotAccept != "image/gif" {
t.Fatalf("Accept = %q, want image/gif", gotAccept)
}
if gotContentType != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", gotContentType)
}
if gotAuthorization != "Bearer secret-token" {
t.Fatalf("Authorization = %q, want bearer token", gotAuthorization)
}
if !slices.Equal(got.Options, []string{"alice", "bob", "carol"}) {
t.Fatalf("options = %#v, want original options", got.Options)
}
if got.WinnerIndex != 1 {
t.Fatalf("winnerIndex = %d, want 1", got.WinnerIndex)
}
assertWheelBetaRemoteDefaults(t, got)
}
func TestWheelBetaAPIClient_RenderWithoutTokenOmitsAuthorization(t *testing.T) {
var gotAuthorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuthorization = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a"))
}))
defer server.Close()
client := wheelBetaAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err != nil {
t.Fatalf("Render: %v", err)
}
if gotAuthorization != "" {
t.Fatalf("Authorization = %q, want empty", gotAuthorization)
}
}
func TestWheelBetaAPIClient_RenderNotConfigured(t *testing.T) {
client := wheelBetaAPIClient{}
_, err := client.Render(context.Background(), []string{"alice"}, 0)
if !errors.Is(err, errWheelBetaAPINotConfigured) {
t.Fatalf("Render error = %v, want errWheelBetaAPINotConfigured", err)
}
}
func TestWheelBetaAPIClient_RenderRejectsInvalidInput(t *testing.T) {
client := wheelBetaAPIClient{URL: "https://example.com/api/gif"}
for _, tc := range []struct {
name string
url string
options []string
winner int
}{
{name: "bad scheme", url: "ftp://example.com/api/gif", options: []string{"alice"}, winner: 0},
{name: "empty options", url: "https://example.com/api/gif", options: nil, winner: 0},
{name: "winner out of range", url: "https://example.com/api/gif", options: []string{"alice"}, winner: 1},
} {
t.Run(tc.name, func(t *testing.T) {
client.URL = tc.url
if _, err := client.Render(context.Background(), tc.options, tc.winner); err == nil {
t.Fatalf("Render returned nil error")
}
})
}
}
func TestWheelBetaAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
for _, tc := range []struct {
name string
status int
contentType string
body []byte
}{
{name: "unauthorized", status: http.StatusUnauthorized, contentType: "text/plain", body: []byte("no")},
{name: "server error", status: http.StatusInternalServerError, contentType: "text/plain", body: []byte("bad")},
{name: "non gif", status: http.StatusOK, contentType: "text/plain", body: []byte("not gif")},
{name: "empty gif", status: http.StatusOK, contentType: "image/gif", body: nil},
{name: "mislabeled gif", status: http.StatusOK, contentType: "image/gif", body: []byte("not gif")},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", tc.contentType)
w.WriteHeader(tc.status)
_, _ = w.Write(tc.body)
}))
defer server.Close()
client := wheelBetaAPIClient{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_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))
}))
defer server.Close()
client := wheelBetaAPIClient{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 assertWheelBetaRemoteDefaults(t *testing.T, got wheelBetaAPIRequest) {
t.Helper()
if got.DurationMs != wheelBetaRemoteDurationMs {
t.Fatalf("durationMs = %d, want %d", got.DurationMs, wheelBetaRemoteDurationMs)
}
if got.HoldMs != wheelBetaRemoteHoldMs {
t.Fatalf("holdMs = %d, want %d", got.HoldMs, wheelBetaRemoteHoldMs)
}
if got.FPS != wheelBetaRemoteFPS {
t.Fatalf("fps = %d, want %d", got.FPS, wheelBetaRemoteFPS)
}
if got.Size != wheelBetaRemoteSize {
t.Fatalf("size = %d, want %d", got.Size, wheelBetaRemoteSize)
}
if got.Theme != wheelBetaRemoteTheme {
t.Fatalf("theme = %q, want %q", got.Theme, wheelBetaRemoteTheme)
}
}
@@ -31,7 +31,7 @@ func wheelOfNamesBetaCommand() modules.Command {
return chathelper.Reply(ctx, b, update.Message, wheelOfNamesBetaUsage)
}
winner := pickWheelOption(options)
data, err := renderWheelOfNamesBetaGIF(options, winner)
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])
@@ -41,11 +41,11 @@ func wheelOfNamesBetaCommand() modules.Command {
MessageThreadID: update.Message.MessageThreadID,
Animation: &models.InputFileUpload{
Filename: wheelBetaFilename,
Data: bytes.NewReader(data),
Data: bytes.NewReader(animation.Data),
},
Duration: wheelBetaDuration,
Width: wheelBetaSize,
Height: wheelBetaSize,
Duration: animation.Duration,
Width: animation.Width,
Height: animation.Height,
Caption: "Spinning...",
})
if err != nil {
@@ -0,0 +1,114 @@
---
phase: 1
title: API Client And Env Config
status: completed
priority: P2
dependencies: []
---
# Phase 1: API Client And Env Config
## Overview
Create a small misc-local HTTP client for the wheelofnames API and load its
endpoint/token from system env. This phase adds the remote render capability
without changing command behavior yet.
## Requirements
- Functional: Read `WHEELOFNAMES_API_URL` and `WHEELOFNAMES_API_TOKEN` from env.
- Functional: POST JSON to the configured full endpoint URL.
- Functional: Include Bearer auth only when token is non-empty.
- Functional: Return GIF bytes and metadata needed by the command.
- Non-functional: Use a bounded `http.Client` timeout, around 30 seconds.
- Non-functional: Never log or expose `WHEELOFNAMES_API_TOKEN`.
- Non-functional: Allow `http://` for private Coolify/Docker networks and
`https://` for public deployments; reject other schemes.
## Architecture
Add `wheelofnames_beta_api_client.go` in package `misc`.
Suggested types:
```go
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"`
}
```
Use a method like:
```go
func (c *wheelBetaAPIClient) Render(ctx context.Context, options []string, winner int) ([]byte, error)
```
Return errors for:
- client not configured
- invalid URL scheme or malformed URL
- request build/transport failure
- non-2xx response
- non-`image/gif` content type
- empty or oversized body
Keep body reads bounded. Use a conservative max such as 12 MiB because the bot
default remote render is now 512px, 20fps, and 7 seconds total.
## Related Code Files
- Create: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta_api_client.go`
- Create: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta_api_client_test.go`
- Modify: none in command path yet
## Implementation Steps
1. Add env constants:
- `WHEELOFNAMES_API_URL`
- `WHEELOFNAMES_API_TOKEN`
2. Add `newWheelBetaAPIClientFromEnv()` that trims env strings.
3. Implement URL validation and request construction.
4. Set request headers:
- `Content-Type: application/json`
- `Accept: image/gif`
- `Authorization: Bearer <token>` when token is non-empty
5. Encode request with fixed render options:
- `durationMs: 6000`
- `holdMs: 1000`
- `fps: 20`
- `size: 512`
- `theme: "classic"`
6. Read response body through `io.LimitReader`.
7. Add focused client tests using `httptest.Server`.
## Success Criteria
- [ ] URL unset returns a typed/configuration error that command can treat as fallback.
- [ ] Valid request test confirms JSON body and winner index.
- [ ] Valid request test confirms default render fields:
`durationMs=6000`, `holdMs=1000`, `fps=20`, `size=512`, `theme=classic`.
- [ ] Token test confirms Bearer auth header.
- [ ] HTTP 500 and 401 return errors without leaking response body as user text.
- [ ] Non-GIF content type returns error.
- [ ] Oversized response returns error.
## Risk Assessment
- Risk: Env URL points at service base URL instead of `/api/gif`.
Mitigation: Plan/docs explicitly define `WHEELOFNAMES_API_URL` as full endpoint.
- Risk: Remote service token appears in logs.
Mitigation: Never log request headers or token value; log only high-level status.
- Risk: 512px/20fps render takes longer than older 384px smoke path.
Mitigation: 30-second HTTP timeout and local fallback; tune only after
production timing data.
@@ -0,0 +1,105 @@
---
phase: 2
title: Command Integration And Fallback
status: completed
priority: P2
dependencies:
- 1
---
# Phase 2: Command Integration And Fallback
## Overview
Wire `/wheelofnamesbeta` to try the remote API first when configured, while
preserving the existing local GIF renderer as fallback and preserving Telegram
upload behavior.
## Requirements
- Functional: Keep `splitWheelOptions` and `pickWheelOption` as the only winner
selection source in the bot.
- Functional: Use remote GIF only when API call succeeds with valid GIF bytes.
- Functional: Fall back to `renderWheelOfNamesBetaGIF(options, winner)` on any
remote error.
- Functional: Preserve `sendAnimation`, `MessageThreadID`, filename, and
non-spoiler caption.
- Non-functional: Remote failure should log enough to diagnose status/error but
should not reply with stack traces or token-bearing details.
- Non-functional: Existing command behavior remains unchanged when env is unset.
## Architecture
Introduce a small orchestration helper in package `misc`:
```go
func renderWheelOfNamesBetaAnimation(ctx context.Context, options []string, winner int) ([]byte, int, int, int, error)
```
Return bytes plus Telegram metadata (`duration`, `width`, `height`). Suggested
metadata:
- Remote success: duration `7`, width/height `512`.
- Local fallback: existing `wheelBetaDuration`, `wheelBetaSize`,
`wheelBetaSize`.
Alternative: return a tiny struct:
```go
type wheelBetaAnimation struct {
Data []byte
Duration int
Width int
Height int
}
```
The command handler should stay simple:
1. parse options
2. pick winner
3. call `renderWheelOfNamesBetaAnimation`
4. send animation
5. on final render/upload failure, text fallback remains `options[winner]`
## Related Code Files
- Modify: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta_command.go`
- Modify: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta.go` only if helper placement requires it
- Modify: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/handlers_test.go`
## Implementation Steps
1. Add the orchestration helper that creates the env client and attempts remote
render.
2. If client is unconfigured, skip remote without warning noise.
3. If client is configured but fails, log warning with safe fields:
- command: `wheelofnamesbeta`
- status/error category
- never token or request body
4. On remote success, return remote bytes and remote metadata.
5. On remote failure, call current local `renderWheelOfNamesBetaGIF`.
6. Update `wheelOfNamesBetaCommand` to use returned metadata in
`SendAnimationParams`.
7. Keep caption exactly `"Spinning..."`.
## Success Criteria
- [ ] URL unset path still sends local GIF and existing tests pass.
- [ ] URL configured path calls remote service and sends remote GIF.
- [ ] Remote success path uses Telegram metadata `duration=7`,
`width=512`, `height=512`.
- [ ] Remote failure path sends local GIF, not text, when local renderer works.
- [ ] Telegram upload failure still replies with the selected winner text.
- [ ] Caption does not contain the winner.
- [ ] Message thread forwarding remains covered.
## Risk Assessment
- Risk: Remote and local metadata diverge.
Mitigation: use explicit metadata struct; tests assert remote width/height and
local compatibility.
- Risk: Remote API chooses a different winner.
Mitigation: always pass `winnerIndex`; ignore remote winner headers for bot
caption/source of truth.
- Risk: A configured but down service makes the command slower.
Mitigation: timeout and fallback; consider lowering timeout later only after
production timing data.
@@ -0,0 +1,88 @@
---
phase: 3
title: Deployment Docs And Env Surfaces
status: completed
priority: P2
dependencies:
- 1
- 2
---
# Phase 3: Deployment Docs And Env Surfaces
## Overview
Document the new optional environment variables across local and Coolify
deployment surfaces so the bot can find the wheelofnames service without code
changes.
## Requirements
- Functional: Document `WHEELOFNAMES_API_URL` as the full `/api/gif` endpoint.
- Functional: Document `WHEELOFNAMES_API_TOKEN` as the Bearer token matching the
wheelofnames service `API_TOKEN`.
- Non-functional: `.env.example` must use placeholders, not real secrets.
- Non-functional: Deploy docs must explain private network HTTP URL vs public
HTTPS URL options.
- Non-functional: Compose comments should not force the feature on by default
if the service is not running.
## Architecture
The bot remains a single long-polling service. The wheelofnames service is a
separate container or external service. The bot uses outbound HTTP only.
The current wheelofnames service `compose.yml` forwards its own env from the
shell with `${VAR:-default}` fallbacks, including `PORT`, `API_TOKEN`,
`MAX_CONCURRENT_RENDERS`, and render limits.
Coolify/private-network example:
```env
WHEELOFNAMES_API_URL=http://wheelofnames:3000/api/gif
WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>
```
Public URL example:
```env
WHEELOFNAMES_API_URL=https://wheelofnames.example.com/api/gif
WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>
```
## Related Code Files
- Modify: `/config/workspace/tiennm99/miti99bot/.env.example`
- Modify: `/config/workspace/tiennm99/miti99bot/compose.yml`
- Modify: `/config/workspace/tiennm99/miti99bot/docs/deploy-coolify-selfhosted.md`
- Optional modify: `/config/workspace/tiennm99/miti99bot/README.md` only if the
command description needs to mention remote rendering.
## Implementation Steps
1. Add optional env entries to `.env.example` under operational settings.
2. Add commented or placeholder entries in `compose.yml` near other operational
env vars.
3. Update deploy docs required/optional env table.
4. Add a short deployment note:
- URL unset = local renderer fallback.
- URL set + token = remote Remotion GIF rendering.
- Token should match wheelofnames service `API_TOKEN`.
- Bot default remote render request is 512px, 20fps, 7 seconds total.
5. Avoid suggesting multiple bot replicas; existing single-replica rule still
applies.
## Success Criteria
- [ ] `.env.example` includes both variables with safe placeholder values.
- [ ] `compose.yml` includes the env names without embedding secrets.
- [ ] Deploy docs tell operators how to wire both services.
- [ ] Docs state fallback behavior when env is absent or remote fails.
## Risk Assessment
- Risk: Docs imply a public bot ingress is required.
Mitigation: explicitly state this is outbound HTTP from the bot to
wheelofnames; Telegram transport stays long polling.
- Risk: Operators put a service base URL without `/api/gif`.
Mitigation: examples show full endpoint and implementation returns clear
safe error/fallback.
@@ -0,0 +1,84 @@
---
phase: 4
title: Validation And Regression Tests
status: completed
priority: P2
dependencies:
- 1
- 2
- 3
---
# Phase 4: Validation And Regression Tests
## Overview
Add focused regression tests and run the repo quality gates required for a
command integration touching HTTP, env config, and Telegram upload behavior.
## Requirements
- Functional: Test remote success, remote failure fallback, token header, and
malformed/non-GIF responses.
- Functional: Existing local renderer tests remain valid.
- Non-functional: Run lint/test gates required by `AGENTS.md`.
- Non-functional: Do not require a live wheelofnames service for unit tests.
## Architecture
Use `httptest.Server` for the wheelofnames API client. Keep tests in package
`misc` so they can use unexported helpers and existing test utilities.
Test matrix:
| Area | Scenario | Expected |
|---|---|---|
| Client | valid `200 image/gif` | returns GIF bytes |
| Client | default request payload | sends 6000ms spin, 1000ms hold, 20fps, 512px, classic |
| Client | token configured | sends `Authorization: Bearer ...` |
| Client | `401`/`500` | returns error |
| Client | `text/plain` 200 | returns error |
| Client | bad URL scheme | returns error |
| Command | env unset | local GIF path still sends animation |
| Command | remote success | upstream receives request; bot sends animation |
| Command | remote failure | bot sends local animation |
| Command | remote success in thread | message thread id preserved |
## Related Code Files
- Create: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta_api_client_test.go`
- Modify: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/handlers_test.go`
- Verify: `/config/workspace/tiennm99/miti99bot/internal/modules/misc/misc_test.go`
## Implementation Steps
1. Write client tests before wiring command behavior where practical.
2. Add command tests with `t.Setenv` and `httptest.Server`.
3. Confirm tests do not depend on real network.
4. Run focused package tests:
```sh
go test ./internal/modules/misc
```
5. Run full gates:
```sh
go test ./...
go vet ./...
```
6. If docs changed only, no Telegram command registration update is needed.
## Success Criteria
- [ ] `go test ./internal/modules/misc` passes.
- [ ] `go test ./...` passes.
- [ ] `go vet ./...` passes.
- [ ] Tests prove winner index is sent to remote API.
- [ ] Tests prove default render config is sent to remote API.
- [ ] Tests prove fallback keeps user-visible behavior stable.
## Risk Assessment
- Risk: Tests become flaky due package-level env.
Mitigation: use `t.Setenv`, avoid `t.Parallel` in env-sensitive tests.
- Risk: Recording bot cannot inspect uploaded file bytes.
Mitigation: assert upstream request count/body plus `sendAnimation` metadata;
client tests validate returned bytes.
@@ -0,0 +1,161 @@
---
title: Wheelofnamesbeta API Integration
description: >-
Route /wheelofnamesbeta GIF rendering through the self-hosted wheelofnames API
when configured, with the current local renderer as fallback.
status: completed
priority: P2
branch: main
tags:
- feature
- backend
- api
blockedBy: []
blocks: []
created: '2026-07-06T15:20:57.412Z'
createdBy: 'ck:plan'
source: skill
---
# Wheelofnamesbeta API Integration
## Overview
Integrate the existing Go `/wheelofnamesbeta` command with the new
`wheelofnames` Remotion service by reading the API endpoint from system env.
When `WHEELOFNAMES_API_URL` is set, the command sends the parsed options and
locally selected `winnerIndex` to that endpoint and uploads the returned GIF to
Telegram. When the env is unset, invalid, timed out, unauthorized, or the
service returns non-GIF/non-2xx, the bot keeps using the current local GIF
renderer.
Checked service repo state on 2026-07-06:
- `/config/workspaces/tiennm99/wheelofnames` is clean.
- Latest commit: `4cf72ea fix: align wheel labels and compose env`.
- Service now has dedicated radial label layout logic in
`src/remotion/wheel-label-layout.js`, tests for label layout, and
`compose.yml` env defaults via `${VAR:-default}`.
- API schema still accepts `durationMs`, `holdMs`, `fps`, `size`, and `theme`.
- Service composition duration is `durationMs + holdMs`; the requested bot
payload below renders a 7-second GIF.
Scope Challenge:
- Existing code: `/wheelofnamesbeta` already parses options, picks the winner,
renders local GIF bytes, sends `sendAnimation`, preserves message threads,
and falls back to text on local render/upload failure.
- Minimum changes: add a small API client, wire command render path through
remote-or-local selection, add env docs, and test remote success/failure.
- Complexity: expected 5-7 touched files, one new client file, four focused
phases. No command rename, stats migration, persistent jobs, or async flow.
- Selected mode: SCOPE REDUCTION / fast plan. The API service already exists;
this plan only integrates the command.
## Architecture
```text
/wheelofnamesbeta message
|
| splitWheelOptions + pickWheelOption (existing)
v
renderWheelOfNamesBetaAnimation(ctx, options, winner)
|
| if WHEELOFNAMES_API_URL unset
| -> existing renderWheelOfNamesBetaGIF fallback
|
| POST WHEELOFNAMES_API_URL
| Authorization: Bearer $WHEELOFNAMES_API_TOKEN when set
| JSON: { options, winnerIndex, durationMs, holdMs, fps, size, theme }
v
remote GIF bytes -> Telegram sendAnimation
|
| on any remote error
v
existing local GIF renderer -> Telegram sendAnimation
```
Environment contract:
- `WHEELOFNAMES_API_URL`: optional full endpoint URL, expected to include
`/api/gif`, for example `http://wheelofnames:3000/api/gif`.
- `WHEELOFNAMES_API_TOKEN`: optional Bearer token. Required for production
`wheelofnames` service deployments because that service refuses production
startup without `API_TOKEN`.
Remote render request values should be fixed in code for now:
```json
{
"options": ["alice", "bob", "carol"],
"winnerIndex": 1,
"durationMs": 6000,
"holdMs": 1000,
"fps": 20,
"size": 512,
"theme": "classic"
}
```
Do not add user-facing flags, persistent storage, or dynamic theme selection in
this integration pass.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [API Client And Env Config](./phase-01-api-client-and-env-config.md) | Completed |
| 2 | [Command Integration And Fallback](./phase-02-command-integration-and-fallback.md) | Completed |
| 3 | [Deployment Docs And Env Surfaces](./phase-03-deployment-docs-and-env-surfaces.md) | Completed |
| 4 | [Validation And Regression Tests](./phase-04-validation-and-regression-tests.md) | Completed |
## Dependencies
## Cross-Plan Dependencies
| Relationship | Plan | Status |
|---|---|---|
| Uses output from | `plans/260706-1441-wheelofnames-remotion-api/plan.md` | completed |
No unfinished project plans overlap this scope.
## Key Files
| File | Action |
|---|---|
| `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta_api_client.go` | Create remote API client and env loader |
| `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta_command.go` | Route command through remote-or-local renderer |
| `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta.go` | Keep local renderer unchanged as fallback |
| `/config/workspace/tiennm99/miti99bot/internal/modules/misc/handlers_test.go` | Add command-level remote success/fallback tests |
| `/config/workspace/tiennm99/miti99bot/internal/modules/misc/wheelofnames_beta_api_client_test.go` | Create focused client tests |
| `/config/workspace/tiennm99/miti99bot/.env.example` | Document optional API env vars |
| `/config/workspace/tiennm99/miti99bot/compose.yml` | Add commented Coolify/private-network env hints |
| `/config/workspace/tiennm99/miti99bot/docs/deploy-coolify-selfhosted.md` | Document deployment wiring with wheelofnames service |
## Acceptance Criteria
- With `WHEELOFNAMES_API_URL` unset, `/wheelofnamesbeta` behaves like today.
- With `WHEELOFNAMES_API_URL` set and remote returns `200 image/gif`, the bot
uploads those GIF bytes via `sendAnimation`.
- The remote request uses `durationMs=6000`, `holdMs=1000`, `fps=20`,
`size=512`, and `theme=classic` by default.
- The request body contains the original parsed options and the locally chosen
`winnerIndex`; the service must not be the source of truth for the winner.
- If `WHEELOFNAMES_API_TOKEN` is set, request includes
`Authorization: Bearer <token>` and tests verify it.
- Remote failures fall back to the existing local GIF renderer and do not reveal
the winner in the caption.
- Timeout is bounded with an HTTP client timeout; no goroutine can hang on a
wedged render service.
- Docs and env examples explain that URL is optional and token is required for
production wheelofnames service auth.
- Focused tests pass, then `go test ./...` and `go vet ./...` pass.
## Out Of Scope
- Replacing `/wheelofnames`; only `/wheelofnamesbeta` changes.
- Removing the local Go GIF renderer.
- Async job polling, object storage, or cached GIF URLs.
- User-selectable themes, sizes, fps, or command syntax changes.
- Stats migrations or Telegram command menu changes.
## Unresolved Questions
None.