diff --git a/.env.example b/.env.example index 100de08..e879f13 100644 --- a/.env.example +++ b/.env.example @@ -38,11 +38,11 @@ STICKER_PACK_NAME=miti99_by_miti99bot # Without it every /lol* fetch fails; the stale cache covers ≤60 min. LOL_PANDASCORE_TOKEN= -# Optional /wheelofnames GIF and /gacha MP4 renderer: the renderer/ service in -# this repository. compose.yml fixes this to http://renderer:3000/api/gif, so -# set it only when running the bot outside compose. /gacha calls /api/gacha on -# the same service. Leave blank to fall back to text selection. -WHEELOFNAMES_API_URL= +# Optional base URL of the animation renderer (the renderer/ service in this +# repository) for /wheelofnames, /gacha and /genshin, e.g. +# http://localhost:3000. compose.yml fixes it to http://renderer:3000, so set it +# only when running the bot outside compose. Leave blank to fall back to text. +RENDERER_URL= # ====================== Leave UNSET on self-host ================== # Defaults are correct for self-host: diff --git a/compose.yml b/compose.yml index 7c77c7b..5b7c1f6 100644 --- a/compose.yml +++ b/compose.yml @@ -15,10 +15,10 @@ services: OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids # The bundled renderer service below draws /wheelofnames, /gacha and - # /genshin. The URL is fixed to its in-network address so a stale - # platform-level value cannot point the bot elsewhere; /gacha and - # /genshin derive their endpoints from it. - WHEELOFNAMES_API_URL: http://renderer:3000/api/gif + # /genshin. Its base URL is fixed to the in-network address so a stale + # platform-level value cannot point the bot elsewhere; the bot appends + # each /api/ route itself. + RENDERER_URL: http://renderer:3000 # 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 023ffd0..5299a00 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -36,7 +36,7 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in. | `ADMIN_IDS` | optional | CSV of Telegram user ids for admin-only commands | | `STICKER_PACK_NAME` | optional | set `/addsticker` writes to; default `miti99_by_miti99bot`. See [sticker packs](sticker-packs.md) | | `LOL_PANDASCORE_TOKEN` | ✅ for lol module | PandaScore API token (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) | -| `WHEELOFNAMES_API_URL` | leave unset | fixed by `compose.yml` to the bundled renderer (`http://renderer:3000/api/gif`); a Coolify value is ignored | +| `RENDERER_URL` | leave unset | base URL of the animation renderer; fixed by `compose.yml` to the bundled renderer (`http://renderer:3000`), so a Coolify value is ignored | | `LOG_LEVEL` | optional | `debug`, `info` (default), `warn`, or `error`; logs are JSON on stdout | | `GOLD_VNAPP_API_KEY` | leave unset | VNAppMob key; unset = the gold module fetches one and caches it in MongoDB | | `KV_PROVIDER` | leave unset | `memory` or `mongodb`; unset = `mongodb` when `MONGO_URL` is set, otherwise `memory` | @@ -55,7 +55,7 @@ has no webhook. `/wheelofnames`, `/gacha`, and `/genshin` are drawn by the Node renderer in [`renderer/`](../renderer/README.md) (Remotion and headless Chrome). `compose.yml` deploys it as a second service, `renderer`, next to the bot. It -is internal only: the bot reaches it at `http://renderer:3000/api/gif` over the +is internal only: the bot reaches it at `http://renderer:3000` over the compose network, so it needs no domain, publishes no port, and takes no auth token. Its API is unauthenticated, so never publish a port or attach a domain to it. @@ -65,8 +65,9 @@ Renderer tuning (`MAX_CONCURRENT_RENDERS`, `RENDER_TIMEOUT_MS`, `MAX_OPTIONS`, [`renderer/docs/deployment.md`](../renderer/docs/deployment.md). Give the host 1-2 GB of headroom for the renderer's Chrome. -Outside compose, `WHEELOFNAMES_API_URL` can point the bot at any service that -implements the same `/api/gif` contract. +Outside compose, set `RENDERER_URL` to the base URL of any service that +implements the same `/api/gif`, `/api/gacha`, and `/api/genshin` routes, e.g. +`http://localhost:3000`. The bot appends each route itself. 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 renderer @@ -80,8 +81,7 @@ render or upload failure edits that same message into the plain text winner instead. With no renderer configured there is no holding message — the winner reply is immediate. -`/gacha` uses the same service and token: the bot swaps the URL's last path -segment, so `.../api/gif` becomes `.../api/gacha`. It renders a 6-second +`/gacha` uses the same service at `/api/gacha`. It renders a 6-second `360x640` portrait silent MP4 wish animation (a card pack torn open), posts `Wishing...` while it renders, and falls back to a text reply such as `★★★★★ Pizza` on the same failures. Every option is equally likely, as with `/random`; the rarity only sets what the diff --git a/internal/modules/random/gacha_command.go b/internal/modules/random/gacha_command.go index 8577060..86448e8 100644 --- a/internal/modules/random/gacha_command.go +++ b/internal/modules/random/gacha_command.go @@ -100,7 +100,7 @@ func newGachaCommand(name string, visibility modules.Visibility, description, us placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder) animation, err := renderGachaAnimation(ctx, style, options[winner].Label, options[winner].Rarity) if err != nil { - if !errors.Is(err, errWheelAPINotConfigured) { + if !errors.Is(err, errRendererNotConfigured) { log.Warn("gacha remote render failed", "command", name, "err", err) } return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) diff --git a/internal/modules/random/gacha_command_test.go b/internal/modules/random/gacha_command_test.go index 1f870c9..e27a733 100644 --- a/internal/modules/random/gacha_command_test.go +++ b/internal/modules/random/gacha_command_test.go @@ -54,7 +54,7 @@ func TestGacha_NotConfiguredRepliesWithStars(t *testing.T) { } func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) { - var got gachaAPIRequest + var got gachaRenderRequest var gotPath, gotAuthorization string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path @@ -66,7 +66,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) { _, _ = w.Write(mp4Bytes) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 4* Pho")) @@ -77,7 +77,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) { if gotAuthorization != "" { t.Fatalf("Authorization = %q, want none", gotAuthorization) } - want := gachaAPIRequest{Label: "Pho", Rarity: 4, FPS: gachaRemoteFPS, Width: gachaRemoteWidth} + want := gachaRenderRequest{Label: "Pho", Rarity: 4, FPS: gachaRemoteFPS, Width: gachaRemoteWidth} if got != want { t.Fatalf("request = %+v, want %+v", got, want) } @@ -111,7 +111,7 @@ func TestGacha_RemoteFailureFallsBackToText(t *testing.T) { http.Error(w, "no", http.StatusInternalServerError) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice")) @@ -131,7 +131,7 @@ func TestGacha_SendAnimationFailureFallsBackToText(t *testing.T) { _, _ = w.Write(mp4Bytes) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") @@ -149,7 +149,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) { _, _ = w.Write(mp4Bytes) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) update := testutil.NewSupergroupMessage(-100, 7, "/gacha 3* Rice") @@ -175,7 +175,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) { func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) { var gotPath string - var got gachaAPIRequest + var got gachaRenderRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path if err := json.NewDecoder(r.Body).Decode(&got); err != nil { @@ -185,7 +185,7 @@ func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) { _, _ = w.Write(mp4Bytes) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) // 7 is neither the owner (999) nor an admin. rb := installRandom(t, 999) @@ -194,7 +194,7 @@ func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) { if gotPath != "/api/genshin" { t.Fatalf("path = %q, want /api/genshin", gotPath) } - if want := (gachaAPIRequest{Label: "Bún bò", Rarity: 5, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}); got != want { + if want := (gachaRenderRequest{Label: "Bún bò", Rarity: 5, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}); got != want { t.Fatalf("request = %+v, want %+v", got, want) } var animation *testutil.SentCall diff --git a/internal/modules/random/gacha_api_client.go b/internal/modules/random/gacha_render.go similarity index 53% rename from internal/modules/random/gacha_api_client.go rename to internal/modules/random/gacha_render.go index a7d5314..97244b2 100644 --- a/internal/modules/random/gacha_api_client.go +++ b/internal/modules/random/gacha_render.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "net/url" "strings" ) @@ -15,8 +14,8 @@ const ( gachaRemoteHeight = 360 ) -// gachaStyle is one wish animation the renderer serves: its path next to -// /api/gif, the clip length in seconds, and the frame size it renders for a +// gachaStyle is one wish animation the renderer serves: its route name under +// /api, the clip length in seconds, and the frame size it renders for a // gachaRemoteWidth request. type gachaStyle struct { Path string @@ -31,49 +30,34 @@ var ( gachaStyleGenshin = gachaStyle{Path: "genshin", Duration: 7, Width: gachaRemoteWidth, Height: gachaRemoteHeight} ) -type gachaAPIRequest struct { +type gachaRenderRequest struct { Label string `json:"label"` Rarity int `json:"rarity"` FPS int `json:"fps"` Width int `json:"width"` } -// gachaAPIEndpoint derives a wish renderer from the configured wheel -// endpoint: the same service serves /api/gacha and /api/genshin next to -// /api/gif, so the last path segment is swapped and no second URL needs -// configuring. -func gachaAPIEndpoint(rawURL, path string) (*url.URL, error) { - endpoint, err := wheelAPIEndpoint(rawURL) - if err != nil { - return nil, err - } - base := *endpoint - base.Path = strings.TrimSuffix(base.Path, "/") - base.RawPath = "" - return base.ResolveReference(&url.URL{Path: path}), nil -} - -// RenderGacha returns the MP4 wish animation at path revealing label at +// RenderGacha returns the MP4 wish animation named path revealing label at // rarity stars. -func (c wheelAPIClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) { - endpoint, err := gachaAPIEndpoint(c.URL, path) +func (c rendererClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) { + endpoint, err := c.endpoint(path) if err != nil { return nil, err } if strings.TrimSpace(label) == "" { - return nil, fmt.Errorf("gacha api label empty") + return nil, fmt.Errorf("renderer gacha label empty") } if rarity < gachaMinRarity || rarity > gachaMaxRarity { - return nil, fmt.Errorf("gacha api rarity %d out of range", rarity) + return nil, fmt.Errorf("renderer gacha rarity %d out of range", rarity) } - body, err := json.Marshal(gachaAPIRequest{ + body, err := json.Marshal(gachaRenderRequest{ Label: label, Rarity: rarity, FPS: gachaRemoteFPS, Width: gachaRemoteWidth, }) if err != nil { - return nil, fmt.Errorf("gacha api request encode failed: %w", err) + return nil, fmt.Errorf("renderer gacha request encode failed: %w", err) } return c.post(ctx, endpoint, body, "video/mp4", isMP4) } @@ -83,12 +67,12 @@ func isMP4(data []byte) bool { return len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp")) } -func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (wheelAnimation, error) { - data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, style.Path, label, rarity) +func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (renderedAnimation, error) { + data, err := newRendererClientFromEnv().RenderGacha(ctx, style.Path, label, rarity) if err != nil { - return wheelAnimation{}, err + return renderedAnimation{}, err } - return wheelAnimation{ + return renderedAnimation{ Data: data, Duration: style.Duration, Width: style.Width, diff --git a/internal/modules/random/gacha_api_client_test.go b/internal/modules/random/gacha_render_test.go similarity index 63% rename from internal/modules/random/gacha_api_client_test.go rename to internal/modules/random/gacha_render_test.go index 90a3f40..2c07e0d 100644 --- a/internal/modules/random/gacha_api_client_test.go +++ b/internal/modules/random/gacha_render_test.go @@ -7,27 +7,28 @@ import ( "testing" ) -func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) { +func TestRendererClient_EndpointJoinsAPIRoute(t *testing.T) { for raw, want := range map[string]string{ - "https://render.example/api/gif": "https://render.example/api/gacha", - "https://render.example/api/gif/": "https://render.example/api/gacha", - "http://10.0.0.5:3000/api/gif": "http://10.0.0.5:3000/api/gacha", + "http://renderer:3000": "http://renderer:3000/api/gacha", + "http://renderer:3000/": "http://renderer:3000/api/gacha", + "https://render.example/proxied": "https://render.example/proxied/api/gacha", + "https://render.example/proxied/": "https://render.example/proxied/api/gacha", } { - got, err := gachaAPIEndpoint(raw, "gacha") + got, err := rendererClient{BaseURL: raw}.endpoint("gacha") if err != nil { - t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err) + t.Fatalf("endpoint(%q): %v", raw, err) } if got.String() != want { - t.Errorf("gachaAPIEndpoint(%q) = %q, want %q", raw, got, want) + t.Errorf("endpoint(%q) = %q, want %q", raw, got, want) } } - if _, err := gachaAPIEndpoint("", "gacha"); err != errWheelAPINotConfigured { - t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err) + if _, err := (rendererClient{}).endpoint("gacha"); err != errRendererNotConfigured { + t.Fatalf("empty url err = %v, want errRendererNotConfigured", err) } } -func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) { - client := wheelAPIClient{URL: "https://example.com/api/gif"} +func TestRendererClient_RenderGachaRejectsInvalidInput(t *testing.T) { + client := rendererClient{BaseURL: "https://example.com"} for _, tc := range []struct { name string label string @@ -45,7 +46,7 @@ func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) { } } -func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) { +func TestRendererClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) { for _, tc := range []struct { name string status int @@ -65,7 +66,7 @@ func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) { })) defer server.Close() - client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} + client := rendererClient{HTTP: server.Client(), BaseURL: server.URL} if _, err := client.RenderGacha(context.Background(), "gacha", "a", 3); err == nil { t.Fatal("RenderGacha returned nil error") } diff --git a/internal/modules/random/handlers_test.go b/internal/modules/random/handlers_test.go index ec338a3..64a88e3 100644 --- a/internal/modules/random/handlers_test.go +++ b/internal/modules/random/handlers_test.go @@ -115,7 +115,7 @@ func TestWheelOfNames_ResultCaptionPadsShortWinnerToLongestOption(t *testing.T) } func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) { - var got wheelAPIRequest + var got wheelRenderRequest var gotAuthorization string var calls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -131,7 +131,7 @@ func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) { _, _ = w.Write([]byte("GIF89a-remote")) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol")) @@ -191,7 +191,7 @@ func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) { http.Error(w, "no", http.StatusInternalServerError) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) @@ -221,7 +221,7 @@ func TestWheelOfNames_PlaceholderEditFailureFallsBackToReply(t *testing.T) { http.Error(w, "no", http.StatusInternalServerError) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) rb.FailMethod("editMessageText", http.StatusInternalServerError, "") @@ -254,7 +254,7 @@ func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) { _, _ = w.Write([]byte("GIF89a-remote")) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") @@ -283,7 +283,7 @@ func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) { _, _ = w.Write([]byte("GIF89a-remote")) })) defer server.Close() - t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + t.Setenv(rendererURLEnv, server.URL) rb := installRandom(t, 999) update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice") diff --git a/internal/modules/random/random.go b/internal/modules/random/random.go index 8b9df80..935fede 100644 --- a/internal/modules/random/random.go +++ b/internal/modules/random/random.go @@ -1,7 +1,7 @@ // Package random groups the commands that pick one option at random: /random // (plain text pick), /wheelofnames (wheel GIF when a renderer is configured), // /gacha (card-pack wish MP4), and the unlisted /genshin (Genshin-style meteor -// wish MP4). The animated commands share the optional wheelofnames renderer and +// wish MP4). The animated commands share the optional renderer service and // fall back to a text reply without it. package random diff --git a/internal/modules/random/renderer_client.go b/internal/modules/random/renderer_client.go new file mode 100644 index 0000000..183bb06 --- /dev/null +++ b/internal/modules/random/renderer_client.go @@ -0,0 +1,196 @@ +package random + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const ( + // rendererURLEnv holds the renderer's base URL, e.g. http://renderer:3000. + // The standard renderer is the renderer/ service in this repository, wired + // in by compose.yml and reachable only on the compose network, so requests + // carry no credentials. The client appends each animation's /api/ + // path itself. + rendererURLEnv = "RENDERER_URL" + + rendererMaxBytes = 12 << 20 + rendererTimeout = 30 * time.Second + + wheelRemoteDurationMs = 6000 + wheelRemoteHoldMs = 1000 + wheelRemoteFPS = 20 + wheelRemoteSize = 512 + wheelRemoteTheme = "classic" + wheelRemoteDuration = (wheelRemoteDurationMs + wheelRemoteHoldMs) / 1000 +) + +var errRendererNotConfigured = errors.New("renderer not configured") + +// rendererClient calls the animation renderer. BaseURL is the service root; +// every animation lives under its /api path. +type rendererClient struct { + HTTP *http.Client + BaseURL string +} + +type wheelRenderRequest 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"` +} + +// renderedAnimation is a rendered clip plus the metadata Telegram's +// sendAnimation wants. +type renderedAnimation struct { + Data []byte + Duration int + Width int + Height int +} + +func newRendererClientFromEnv() rendererClient { + return rendererClient{ + BaseURL: strings.TrimSpace(os.Getenv(rendererURLEnv)), + } +} + +// RenderWheel returns the wheel-of-names GIF landing on options[winner]. +func (c rendererClient) RenderWheel(ctx context.Context, options []string, winner int) ([]byte, error) { + endpoint, err := c.endpoint("gif") + if err != nil { + return nil, err + } + if len(options) == 0 { + return nil, fmt.Errorf("renderer wheel options empty") + } + if winner < 0 || winner >= len(options) { + return nil, fmt.Errorf("renderer wheel winner index %d out of range %d", winner, len(options)) + } + + body, err := json.Marshal(wheelRenderRequest{ + Options: options, + WinnerIndex: winner, + DurationMs: wheelRemoteDurationMs, + HoldMs: wheelRemoteHoldMs, + FPS: wheelRemoteFPS, + Size: wheelRemoteSize, + Theme: wheelRemoteTheme, + }) + if err != nil { + return nil, fmt.Errorf("renderer wheel request encode failed: %w", err) + } + + return c.post(ctx, endpoint, body, "image/gif", isWheelGIF) +} + +// post sends one JSON render request to endpoint and returns the response +// body once it is a 2xx of the expected media type that passes isValid. +func (c rendererClient) post(ctx context.Context, endpoint *url.URL, body []byte, mediaType string, isValid func([]byte) bool) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("renderer request build failed: %w", err) + } + req.Header.Set("Accept", mediaType) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, errors.New("renderer request failed") + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("renderer status %d", resp.StatusCode) + } + if err := requireContentType(resp.Header.Get("Content-Type"), mediaType); err != nil { + return nil, err + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, rendererMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("renderer response read failed: %w", err) + } + if len(data) > rendererMaxBytes { + return nil, fmt.Errorf("renderer response too large") + } + if len(data) == 0 { + return nil, fmt.Errorf("renderer response empty") + } + if !isValid(data) { + return nil, fmt.Errorf("renderer response is not %s", mediaType) + } + return data, nil +} + +func (c rendererClient) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: rendererTimeout} +} + +// endpoint resolves the renderer route /api/ under BaseURL. A path +// prefix on BaseURL is kept, so a renderer mounted below a reverse-proxy path +// still works. +func (c rendererClient) endpoint(name string) (*url.URL, error) { + base, err := rendererBaseURL(c.BaseURL) + if err != nil { + return nil, err + } + return base.JoinPath("api", name), nil +} + +// rendererBaseURL validates the configured base URL. +func rendererBaseURL(rawURL string) (*url.URL, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, errRendererNotConfigured + } + base, err := url.Parse(rawURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("renderer url invalid") + } + if base.Scheme != "http" && base.Scheme != "https" { + return nil, fmt.Errorf("renderer url scheme %q unsupported", base.Scheme) + } + return base, nil +} + +func requireContentType(contentType, want string) error { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != want { + return fmt.Errorf("renderer content type %q unsupported", contentType) + } + return nil +} + +func isWheelGIF(data []byte) bool { + return bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a")) +} + +func renderWheelOfNamesAnimation(ctx context.Context, options []string, winner int) (renderedAnimation, error) { + data, err := newRendererClientFromEnv().RenderWheel(ctx, options, winner) + if err != nil { + return renderedAnimation{}, err + } + return renderedAnimation{ + Data: data, + Duration: wheelRemoteDuration, + Width: wheelRemoteSize, + Height: wheelRemoteSize, + }, nil +} diff --git a/internal/modules/random/wheelofnames_api_client_test.go b/internal/modules/random/renderer_client_test.go similarity index 61% rename from internal/modules/random/wheelofnames_api_client_test.go rename to internal/modules/random/renderer_client_test.go index 50cb6b9..55bdf14 100644 --- a/internal/modules/random/wheelofnames_api_client_test.go +++ b/internal/modules/random/renderer_client_test.go @@ -11,8 +11,8 @@ import ( "testing" ) -func TestWheelAPIClient_RenderValidRequest(t *testing.T) { - var got wheelAPIRequest +func TestRendererClient_RenderValidRequest(t *testing.T) { + var got wheelRenderRequest var gotAccept string var gotContentType string var gotMethod string @@ -30,13 +30,13 @@ func TestWheelAPIClient_RenderValidRequest(t *testing.T) { })) defer server.Close() - client := wheelAPIClient{ - HTTP: server.Client(), - URL: server.URL + "/api/gif", + client := rendererClient{ + HTTP: server.Client(), + BaseURL: server.URL, } - data, err := client.Render(context.Background(), []string{"alice", "bob", "carol"}, 1) + data, err := client.RenderWheel(context.Background(), []string{"alice", "bob", "carol"}, 1) if err != nil { - t.Fatalf("Render: %v", err) + t.Fatalf("RenderWheel: %v", err) } if !bytes.Equal(data, []byte("GIF89a-remote")) { t.Fatalf("data = %q, want remote GIF bytes", data) @@ -63,7 +63,7 @@ func TestWheelAPIClient_RenderValidRequest(t *testing.T) { } // The renderer is internal to the compose network; the bot sends no credentials. -func TestWheelAPIClient_RenderSendsNoAuthorization(t *testing.T) { +func TestRendererClient_RenderSendsNoAuthorization(t *testing.T) { var gotAuthorization string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuthorization = r.Header.Get("Authorization") @@ -72,45 +72,45 @@ func TestWheelAPIClient_RenderSendsNoAuthorization(t *testing.T) { })) defer server.Close() - client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} - if _, err := client.Render(context.Background(), []string{"alice"}, 0); err != nil { - t.Fatalf("Render: %v", err) + client := rendererClient{HTTP: server.Client(), BaseURL: server.URL} + if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err != nil { + t.Fatalf("RenderWheel: %v", err) } if gotAuthorization != "" { t.Fatalf("Authorization = %q, want empty", gotAuthorization) } } -func TestWheelAPIClient_RenderNotConfigured(t *testing.T) { - client := wheelAPIClient{} - _, err := client.Render(context.Background(), []string{"alice"}, 0) - if !errors.Is(err, errWheelAPINotConfigured) { - t.Fatalf("Render error = %v, want errWheelAPINotConfigured", err) +func TestRendererClient_RenderNotConfigured(t *testing.T) { + client := rendererClient{} + _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0) + if !errors.Is(err, errRendererNotConfigured) { + t.Fatalf("RenderWheel error = %v, want errRendererNotConfigured", err) } } -func TestWheelAPIClient_RenderRejectsInvalidInput(t *testing.T) { - client := wheelAPIClient{URL: "https://example.com/api/gif"} +func TestRendererClient_RenderRejectsInvalidInput(t *testing.T) { + client := rendererClient{BaseURL: "https://example.com"} 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}, + {name: "bad scheme", url: "ftp://example.com", options: []string{"alice"}, winner: 0}, + {name: "empty options", url: "https://example.com", options: nil, winner: 0}, + {name: "winner out of range", url: "https://example.com", 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") + client.BaseURL = tc.url + if _, err := client.RenderWheel(context.Background(), tc.options, tc.winner); err == nil { + t.Fatalf("RenderWheel returned nil error") } }) } } -func TestWheelAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) { +func TestRendererClient_RenderReturnsErrorsForBadResponses(t *testing.T) { for _, tc := range []struct { name string status int @@ -131,35 +131,35 @@ func TestWheelAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) { })) defer server.Close() - client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} - if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil { - t.Fatalf("Render returned nil error") + client := rendererClient{HTTP: server.Client(), BaseURL: server.URL} + if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err == nil { + t.Fatalf("RenderWheel returned nil error") } }) } } -func TestWheelAPIClient_RenderRejectsOversizedResponse(t *testing.T) { +func TestRendererClient_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(wheelRemoteMaxBytes)+1)) + _, _ = w.Write(bytes.Repeat([]byte("a"), int(rendererMaxBytes)+1)) })) defer server.Close() - client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} - if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil { - t.Fatalf("Render returned nil error") + client := rendererClient{HTTP: server.Client(), BaseURL: server.URL} + if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err == nil { + t.Fatalf("RenderWheel returned nil error") } } -func TestWheelAPIClient_DefaultHTTPClientHasTimeout(t *testing.T) { - client := wheelAPIClient{} - if got := client.httpClient().Timeout; got != wheelRemoteTimeout { - t.Fatalf("timeout = %s, want %s", got, wheelRemoteTimeout) +func TestRendererClient_DefaultHTTPClientHasTimeout(t *testing.T) { + client := rendererClient{} + if got := client.httpClient().Timeout; got != rendererTimeout { + t.Fatalf("timeout = %s, want %s", got, rendererTimeout) } } -func assertWheelRemoteDefaults(t *testing.T, got wheelAPIRequest) { +func assertWheelRemoteDefaults(t *testing.T, got wheelRenderRequest) { t.Helper() if got.DurationMs != wheelRemoteDurationMs { t.Fatalf("durationMs = %d, want %d", got.DurationMs, wheelRemoteDurationMs) diff --git a/internal/modules/random/wheelofnames_api_client.go b/internal/modules/random/wheelofnames_api_client.go deleted file mode 100644 index ab55181..0000000 --- a/internal/modules/random/wheelofnames_api_client.go +++ /dev/null @@ -1,179 +0,0 @@ -package random - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "os" - "strings" - "time" -) - -const ( - // The standard renderer is the renderer/ service in this repository, wired - // in by compose.yml and reachable only on the compose network, so requests - // carry no credentials. Operators may point this to any service that - // implements the same /api/gif contract. - wheelOfNamesAPIURLEnv = "WHEELOFNAMES_API_URL" - - wheelRemoteDurationMs = 6000 - wheelRemoteHoldMs = 1000 - wheelRemoteFPS = 20 - wheelRemoteSize = 512 - wheelRemoteTheme = "classic" - wheelRemoteDuration = (wheelRemoteDurationMs + wheelRemoteHoldMs) / 1000 - wheelRemoteMaxBytes = 12 << 20 - wheelRemoteTimeout = 30 * time.Second -) - -var errWheelAPINotConfigured = errors.New("wheelofnames api not configured") - -type wheelAPIClient struct { - HTTP *http.Client - URL string -} - -type wheelAPIRequest struct { - Options []string `json:"options"` - WinnerIndex int `json:"winnerIndex"` - DurationMs int `json:"durationMs"` - HoldMs int `json:"holdMs"` - FPS int `json:"fps"` - Size int `json:"size"` - Theme string `json:"theme"` -} - -type wheelAnimation struct { - Data []byte - Duration int - Width int - Height int -} - -func newWheelAPIClientFromEnv() wheelAPIClient { - return wheelAPIClient{ - URL: strings.TrimSpace(os.Getenv(wheelOfNamesAPIURLEnv)), - } -} - -func (c wheelAPIClient) Render(ctx context.Context, options []string, winner int) ([]byte, error) { - endpoint, err := wheelAPIEndpoint(c.URL) - if err != nil { - return nil, err - } - if len(options) == 0 { - return nil, fmt.Errorf("wheelofnames api options empty") - } - if winner < 0 || winner >= len(options) { - return nil, fmt.Errorf("wheelofnames api winner index %d out of range %d", winner, len(options)) - } - - body, err := json.Marshal(wheelAPIRequest{ - Options: options, - WinnerIndex: winner, - DurationMs: wheelRemoteDurationMs, - HoldMs: wheelRemoteHoldMs, - FPS: wheelRemoteFPS, - Size: wheelRemoteSize, - Theme: wheelRemoteTheme, - }) - if err != nil { - return nil, fmt.Errorf("wheelofnames api request encode failed: %w", err) - } - - return c.post(ctx, endpoint, body, "image/gif", isWheelGIF) -} - -// post sends one JSON render request to endpoint and returns the response -// body once it is a 2xx of the expected media type that passes isValid. The -// gif and gacha renderers share it because they live on the same service. -func (c wheelAPIClient) post(ctx context.Context, endpoint *url.URL, body []byte, mediaType string, isValid func([]byte) bool) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("wheelofnames api request build failed: %w", err) - } - req.Header.Set("Accept", mediaType) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient().Do(req) - if err != nil { - return nil, errors.New("wheelofnames api request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, fmt.Errorf("wheelofnames api status %d", resp.StatusCode) - } - if err := requireContentType(resp.Header.Get("Content-Type"), mediaType); err != nil { - return nil, err - } - - data, err := io.ReadAll(io.LimitReader(resp.Body, wheelRemoteMaxBytes+1)) - if err != nil { - return nil, fmt.Errorf("wheelofnames api response read failed: %w", err) - } - if len(data) > wheelRemoteMaxBytes { - return nil, fmt.Errorf("wheelofnames api response too large") - } - if len(data) == 0 { - return nil, fmt.Errorf("wheelofnames api response empty") - } - if !isValid(data) { - return nil, fmt.Errorf("wheelofnames api response is not %s", mediaType) - } - return data, nil -} - -func (c wheelAPIClient) httpClient() *http.Client { - if c.HTTP != nil { - return c.HTTP - } - return &http.Client{Timeout: wheelRemoteTimeout} -} - -func wheelAPIEndpoint(rawURL string) (*url.URL, error) { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return nil, errWheelAPINotConfigured - } - endpoint, err := url.Parse(rawURL) - if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { - return nil, fmt.Errorf("wheelofnames api url invalid") - } - if endpoint.Scheme != "http" && endpoint.Scheme != "https" { - return nil, fmt.Errorf("wheelofnames api url scheme %q unsupported", endpoint.Scheme) - } - return endpoint, nil -} - -func requireContentType(contentType, want string) error { - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil || mediaType != want { - return fmt.Errorf("wheelofnames api content type %q unsupported", contentType) - } - return nil -} - -func isWheelGIF(data []byte) bool { - return bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a")) -} - -func renderWheelOfNamesAnimation(ctx context.Context, options []string, winner int) (wheelAnimation, error) { - client := newWheelAPIClientFromEnv() - data, err := client.Render(ctx, options, winner) - if err != nil { - return wheelAnimation{}, err - } - return wheelAnimation{ - Data: data, - Duration: wheelRemoteDuration, - Width: wheelRemoteSize, - Height: wheelRemoteSize, - }, nil -} diff --git a/internal/modules/random/wheelofnames_command.go b/internal/modules/random/wheelofnames_command.go index ac4246f..3d6252d 100644 --- a/internal/modules/random/wheelofnames_command.go +++ b/internal/modules/random/wheelofnames_command.go @@ -38,7 +38,7 @@ func wheelOfNamesCommand() modules.Command { placeholder := sendRenderPlaceholder(ctx, b, update.Message, wheelPlaceholder) animation, err := renderWheelOfNamesAnimation(ctx, options, winner) if err != nil { - if !errors.Is(err, errWheelAPINotConfigured) { + if !errors.Is(err, errRendererNotConfigured) { log.Warn("wheelofnames remote render failed", "err", err) } return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, options[winner]) @@ -81,12 +81,12 @@ const wheelPlaceholder = "Spinning..." // is immediate, and a placeholder would only flash. A failed placeholder is // non-fatal — the pick still resolves, just without the holding message. func sendRenderPlaceholder(ctx context.Context, b *bot.Bot, msg *models.Message, text string) int { - if _, err := wheelAPIEndpoint(newWheelAPIClientFromEnv().URL); err != nil { + if _, err := rendererBaseURL(newRendererClientFromEnv().BaseURL); err != nil { return 0 } id, err := chathelper.SendText(ctx, b, msg, text) if err != nil { - log.Warn("wheelofnames placeholder send failed", "chat", msg.Chat.ID, "err", err) + log.Warn("render placeholder send failed", "chat", msg.Chat.ID, "err", err) return 0 } return id @@ -101,7 +101,7 @@ func replaceWheelPlaceholder(ctx context.Context, b *bot.Bot, msg *models.Messag if err == nil { return nil } - log.Warn("wheelofnames placeholder edit failed", "chat", msg.Chat.ID, "err", err) + log.Warn("render placeholder edit failed", "chat", msg.Chat.ID, "err", err) } return chathelper.Reply(ctx, b, msg, winner) } diff --git a/renderer/README.md b/renderer/README.md index 0609dfd..c125533 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -177,8 +177,8 @@ Use a container runtime first. Static-only platforms cannot satisfy dependencies, and FFmpeg/compositor support. ```sh -docker build -t wheelofnames . -docker run --rm -p 3000:3000 wheelofnames +docker build -t miti99bot-renderer . +docker run --rm -p 3000:3000 miti99bot-renderer ``` Recommended starting resources: 1-2 vCPU and 1-2 GB RAM, with diff --git a/renderer/docs/miti99bot-integration.md b/renderer/docs/miti99bot-integration.md index 6ed4046..d129797 100644 --- a/renderer/docs/miti99bot-integration.md +++ b/renderer/docs/miti99bot-integration.md @@ -1,7 +1,7 @@ # miti99bot Integration miti99bot's `/wheelofnames` renders its wheel with this service, configured -by `WHEELOFNAMES_API_URL` (pointing at `/api/gif`). The service is internal to +by `RENDERER_URL`, the service's base URL. The service is internal to the compose network, so requests carry no credentials. Winner handling stays explicit: @@ -43,8 +43,7 @@ winner selection is used. ## Gacha `/gacha` in miti99bot calls `POST /api/gacha` on the same service. -The bot derives the endpoint from `WHEELOFNAMES_API_URL` by replacing its last -path segment (`/api/gif` becomes `/api/gacha`), picks the result and rarity +The bot appends the route to `RENDERER_URL`, picks the result and rarity itself, and sends the MP4 with `sendAnimation`. On any failure it falls back to a text reply.