refactor(random)!: configure the renderer by base URL as RENDERER_URL

The bot now takes the renderer's base URL and appends each /api/<name>
route itself, instead of taking the full /api/gif endpoint and swapping
its last path segment for /gacha and /genshin. The client, its files, and
its errors are named after the renderer rather than wheelofnames; the
/wheelofnames command keeps its name.

BREAKING CHANGE: WHEELOFNAMES_API_URL is replaced by RENDERER_URL, which
holds the base URL (e.g. http://renderer:3000) rather than the /api/gif
endpoint. compose.yml sets it, so Coolify needs no value.
This commit is contained in:
tiennm99 committed 2026-10-03 11:52:50 +07:00
1 parent 2ec4a2b371
commit 277c913f71
15 files changed
+302 -301

No files matched your search

+5 -5
View File
@@ -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:
+4 -4
View File
@@ -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/<name> 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.
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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])
@@ -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
@@ -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,
@@ -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")
}
+6 -6
View File
@@ -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")
+1 -1
View File
@@ -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
+196
View File
@@ -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/<name>
// 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/<name> 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
}
@@ -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)
@@ -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
}
@@ -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)
}
+2 -2
View File
@@ -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
+2 -3
View File
@@ -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.