From 044eab1e577e4b7d52617e93acff1121ad3c3c32 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 7 Jul 2026 00:12:58 +0700 Subject: [PATCH] feat(misc): add wheelofnamesbeta render API --- .env.example | 6 + compose.yml | 2 + docs/deploy-coolify-selfhosted.md | 28 +++ internal/modules/misc/handlers_test.go | 99 ++++++++++ .../misc/wheelofnames_beta_api_client.go | 187 ++++++++++++++++++ .../misc/wheelofnames_beta_api_client_test.go | 184 +++++++++++++++++ .../modules/misc/wheelofnames_beta_command.go | 10 +- .../phase-01-api-client-and-env-config.md | 114 +++++++++++ ...ase-02-command-integration-and-fallback.md | 105 ++++++++++ ...ase-03-deployment-docs-and-env-surfaces.md | 88 +++++++++ ...hase-04-validation-and-regression-tests.md | 84 ++++++++ .../plan.md | 161 +++++++++++++++ 12 files changed, 1063 insertions(+), 5 deletions(-) create mode 100644 internal/modules/misc/wheelofnames_beta_api_client.go create mode 100644 internal/modules/misc/wheelofnames_beta_api_client_test.go create mode 100644 plans/260706-2220-wheelofnamesbeta-api-integration/phase-01-api-client-and-env-config.md create mode 100644 plans/260706-2220-wheelofnamesbeta-api-integration/phase-02-command-integration-and-fallback.md create mode 100644 plans/260706-2220-wheelofnamesbeta-api-integration/phase-03-deployment-docs-and-env-surfaces.md create mode 100644 plans/260706-2220-wheelofnamesbeta-api-integration/phase-04-validation-and-regression-tests.md create mode 100644 plans/260706-2220-wheelofnamesbeta-api-integration/plan.md diff --git a/.env.example b/.env.example index 918d8bf..ef441af 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/compose.yml b/compose.yml index 8340ffc..e99ba06 100644 --- a/compose.yml +++ b/compose.yml @@ -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. diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index ba8ef2b..d0fd138 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -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= +``` + +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= +``` + +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). diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index ccd1854..74d6b99 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -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") diff --git a/internal/modules/misc/wheelofnames_beta_api_client.go b/internal/modules/misc/wheelofnames_beta_api_client.go new file mode 100644 index 0000000..0b88720 --- /dev/null +++ b/internal/modules/misc/wheelofnames_beta_api_client.go @@ -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 +} diff --git a/internal/modules/misc/wheelofnames_beta_api_client_test.go b/internal/modules/misc/wheelofnames_beta_api_client_test.go new file mode 100644 index 0000000..360b4f4 --- /dev/null +++ b/internal/modules/misc/wheelofnames_beta_api_client_test.go @@ -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) + } +} diff --git a/internal/modules/misc/wheelofnames_beta_command.go b/internal/modules/misc/wheelofnames_beta_command.go index c78d6aa..fe5bd53 100644 --- a/internal/modules/misc/wheelofnames_beta_command.go +++ b/internal/modules/misc/wheelofnames_beta_command.go @@ -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 { diff --git a/plans/260706-2220-wheelofnamesbeta-api-integration/phase-01-api-client-and-env-config.md b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-01-api-client-and-env-config.md new file mode 100644 index 0000000..f62b0ec --- /dev/null +++ b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-01-api-client-and-env-config.md @@ -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 ` 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. diff --git a/plans/260706-2220-wheelofnamesbeta-api-integration/phase-02-command-integration-and-fallback.md b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-02-command-integration-and-fallback.md new file mode 100644 index 0000000..911d8f2 --- /dev/null +++ b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-02-command-integration-and-fallback.md @@ -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. diff --git a/plans/260706-2220-wheelofnamesbeta-api-integration/phase-03-deployment-docs-and-env-surfaces.md b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-03-deployment-docs-and-env-surfaces.md new file mode 100644 index 0000000..f9e2d46 --- /dev/null +++ b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-03-deployment-docs-and-env-surfaces.md @@ -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= +``` + +Public URL example: + +```env +WHEELOFNAMES_API_URL=https://wheelofnames.example.com/api/gif +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. diff --git a/plans/260706-2220-wheelofnamesbeta-api-integration/phase-04-validation-and-regression-tests.md b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-04-validation-and-regression-tests.md new file mode 100644 index 0000000..8c8562a --- /dev/null +++ b/plans/260706-2220-wheelofnamesbeta-api-integration/phase-04-validation-and-regression-tests.md @@ -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. diff --git a/plans/260706-2220-wheelofnamesbeta-api-integration/plan.md b/plans/260706-2220-wheelofnamesbeta-api-integration/plan.md new file mode 100644 index 0000000..c5199ff --- /dev/null +++ b/plans/260706-2220-wheelofnamesbeta-api-integration/plan.md @@ -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 ` 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.