mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-24 02:25:37 +00:00
feat(misc): add wheelofnamesbeta render API
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+114
@@ -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.
|
||||
+105
@@ -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.
|
||||
+88
@@ -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.
|
||||
+84
@@ -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.
|
||||
Reference in New Issue
Block a user