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. # Without it every /lol* fetch fails; the stale cache covers ≤60 min.
LOL_PANDASCORE_TOKEN= LOL_PANDASCORE_TOKEN=
# Optional /wheelofnames GIF and /gacha MP4 renderer: the renderer/ service in # Optional base URL of the animation renderer (the renderer/ service in this
# this repository. compose.yml fixes this to http://renderer:3000/api/gif, so # repository) for /wheelofnames, /gacha and /genshin, e.g.
# set it only when running the bot outside compose. /gacha calls /api/gacha on # http://localhost:3000. compose.yml fixes it to http://renderer:3000, so set it
# the same service. Leave blank to fall back to text selection. # only when running the bot outside compose. Leave blank to fall back to text.
WHEELOFNAMES_API_URL= RENDERER_URL=
# ====================== Leave UNSET on self-host ================== # ====================== Leave UNSET on self-host ==================
# Defaults are correct for 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 OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands
ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids
# The bundled renderer service below draws /wheelofnames, /gacha and # The bundled renderer service below draws /wheelofnames, /gacha and
# /genshin. The URL is fixed to its in-network address so a stale # /genshin. Its base URL is fixed to the in-network address so a stale
# platform-level value cannot point the bot elsewhere; /gacha and # platform-level value cannot point the bot elsewhere; the bot appends
# /genshin derive their endpoints from it. # each /api/<name> route itself.
WHEELOFNAMES_API_URL: http://renderer:3000/api/gif RENDERER_URL: http://renderer:3000
# SOURCE_COMMIT is intentionally not declared here. Coolify provides it # SOURCE_COMMIT is intentionally not declared here. Coolify provides it
# at runtime via its generated env file; declaring it here with Compose # at runtime via its generated env file; declaring it here with Compose
# interpolation can override the runtime value with an empty string. # 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 | | `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) | | `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) | | `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 | | `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 | | `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` | | `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 `/wheelofnames`, `/gacha`, and `/genshin` are drawn by the Node renderer in
[`renderer/`](../renderer/README.md) (Remotion and headless Chrome). [`renderer/`](../renderer/README.md) (Remotion and headless Chrome).
`compose.yml` deploys it as a second service, `renderer`, next to the bot. It `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 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 token. Its API is unauthenticated, so never publish a port or attach a domain
to it. 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 [`renderer/docs/deployment.md`](../renderer/docs/deployment.md). Give the host
1-2 GB of headroom for the renderer's Chrome. 1-2 GB of headroom for the renderer's Chrome.
Outside compose, `WHEELOFNAMES_API_URL` can point the bot at any service that Outside compose, set `RENDERER_URL` to the base URL of any service that
implements the same `/api/gif` contract. 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 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 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 instead. With no renderer configured there is no holding message — the winner
reply is immediate. reply is immediate.
`/gacha` uses the same service and token: the bot swaps the URL's last path `/gacha` uses the same service at `/api/gacha`. It renders a 6-second
segment, so `.../api/gif` becomes `.../api/gacha`. It renders a 6-second
`360x640` portrait silent MP4 wish animation (a card pack torn open), posts `Wishing...` while it renders, and `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 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 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) placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder)
animation, err := renderGachaAnimation(ctx, style, options[winner].Label, options[winner].Rarity) animation, err := renderGachaAnimation(ctx, style, options[winner].Label, options[winner].Rarity)
if err != nil { if err != nil {
if !errors.Is(err, errWheelAPINotConfigured) { if !errors.Is(err, errRendererNotConfigured) {
log.Warn("gacha remote render failed", "command", name, "err", err) log.Warn("gacha remote render failed", "command", name, "err", err)
} }
return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) 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) { func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got gachaAPIRequest var got gachaRenderRequest
var gotPath, gotAuthorization string var gotPath, gotAuthorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path gotPath = r.URL.Path
@@ -66,7 +66,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
_, _ = w.Write(mp4Bytes) _, _ = w.Write(mp4Bytes)
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 4* Pho")) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 4* Pho"))
@@ -77,7 +77,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
if gotAuthorization != "" { if gotAuthorization != "" {
t.Fatalf("Authorization = %q, want none", 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 { if got != want {
t.Fatalf("request = %+v, want %+v", 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) http.Error(w, "no", http.StatusInternalServerError)
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice")) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice"))
@@ -131,7 +131,7 @@ func TestGacha_SendAnimationFailureFallsBackToText(t *testing.T) {
_, _ = w.Write(mp4Bytes) _, _ = w.Write(mp4Bytes)
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") rb.FailMethod("sendAnimation", http.StatusInternalServerError, "")
@@ -149,7 +149,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) {
_, _ = w.Write(mp4Bytes) _, _ = w.Write(mp4Bytes)
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/gacha 3* Rice") update := testutil.NewSupergroupMessage(-100, 7, "/gacha 3* Rice")
@@ -175,7 +175,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) {
func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) { func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) {
var gotPath string var gotPath string
var got gachaAPIRequest var got gachaRenderRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&got); err != nil { if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
@@ -185,7 +185,7 @@ func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) {
_, _ = w.Write(mp4Bytes) _, _ = w.Write(mp4Bytes)
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
// 7 is neither the owner (999) nor an admin. // 7 is neither the owner (999) nor an admin.
rb := installRandom(t, 999) rb := installRandom(t, 999)
@@ -194,7 +194,7 @@ func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) {
if gotPath != "/api/genshin" { if gotPath != "/api/genshin" {
t.Fatalf("path = %q, want /api/genshin", gotPath) 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) t.Fatalf("request = %+v, want %+v", got, want)
} }
var animation *testutil.SentCall var animation *testutil.SentCall
@@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strings" "strings"
) )
@@ -15,8 +14,8 @@ const (
gachaRemoteHeight = 360 gachaRemoteHeight = 360
) )
// gachaStyle is one wish animation the renderer serves: its path next to // gachaStyle is one wish animation the renderer serves: its route name under
// /api/gif, the clip length in seconds, and the frame size it renders for a // /api, the clip length in seconds, and the frame size it renders for a
// gachaRemoteWidth request. // gachaRemoteWidth request.
type gachaStyle struct { type gachaStyle struct {
Path string Path string
@@ -31,49 +30,34 @@ var (
gachaStyleGenshin = gachaStyle{Path: "genshin", Duration: 7, Width: gachaRemoteWidth, Height: gachaRemoteHeight} gachaStyleGenshin = gachaStyle{Path: "genshin", Duration: 7, Width: gachaRemoteWidth, Height: gachaRemoteHeight}
) )
type gachaAPIRequest struct { type gachaRenderRequest struct {
Label string `json:"label"` Label string `json:"label"`
Rarity int `json:"rarity"` Rarity int `json:"rarity"`
FPS int `json:"fps"` FPS int `json:"fps"`
Width int `json:"width"` Width int `json:"width"`
} }
// gachaAPIEndpoint derives a wish renderer from the configured wheel // RenderGacha returns the MP4 wish animation named path revealing label at
// 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
// rarity stars. // rarity stars.
func (c wheelAPIClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) { func (c rendererClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) {
endpoint, err := gachaAPIEndpoint(c.URL, path) endpoint, err := c.endpoint(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if strings.TrimSpace(label) == "" { 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 { 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, Label: label,
Rarity: rarity, Rarity: rarity,
FPS: gachaRemoteFPS, FPS: gachaRemoteFPS,
Width: gachaRemoteWidth, Width: gachaRemoteWidth,
}) })
if err != nil { 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) 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")) return len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp"))
} }
func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (wheelAnimation, error) { func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (renderedAnimation, error) {
data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, style.Path, label, rarity) data, err := newRendererClientFromEnv().RenderGacha(ctx, style.Path, label, rarity)
if err != nil { if err != nil {
return wheelAnimation{}, err return renderedAnimation{}, err
} }
return wheelAnimation{ return renderedAnimation{
Data: data, Data: data,
Duration: style.Duration, Duration: style.Duration,
Width: style.Width, Width: style.Width,
@@ -7,27 +7,28 @@ import (
"testing" "testing"
) )
func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) { func TestRendererClient_EndpointJoinsAPIRoute(t *testing.T) {
for raw, want := range map[string]string{ for raw, want := range map[string]string{
"https://render.example/api/gif": "https://render.example/api/gacha", "http://renderer:3000": "http://renderer:3000/api/gacha",
"https://render.example/api/gif/": "https://render.example/api/gacha", "http://renderer:3000/": "http://renderer:3000/api/gacha",
"http://10.0.0.5:3000/api/gif": "http://10.0.0.5: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 { if err != nil {
t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err) t.Fatalf("endpoint(%q): %v", raw, err)
} }
if got.String() != want { 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 { if _, err := (rendererClient{}).endpoint("gacha"); err != errRendererNotConfigured {
t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err) t.Fatalf("empty url err = %v, want errRendererNotConfigured", err)
} }
} }
func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) { func TestRendererClient_RenderGachaRejectsInvalidInput(t *testing.T) {
client := wheelAPIClient{URL: "https://example.com/api/gif"} client := rendererClient{BaseURL: "https://example.com"}
for _, tc := range []struct { for _, tc := range []struct {
name string name string
label 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 { for _, tc := range []struct {
name string name string
status int status int
@@ -65,7 +66,7 @@ func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) {
})) }))
defer server.Close() 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 { if _, err := client.RenderGacha(context.Background(), "gacha", "a", 3); err == nil {
t.Fatal("RenderGacha returned nil error") 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) { func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got wheelAPIRequest var got wheelRenderRequest
var gotAuthorization string var gotAuthorization string
var calls int var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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")) _, _ = w.Write([]byte("GIF89a-remote"))
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol")) 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) http.Error(w, "no", http.StatusInternalServerError)
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) 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) http.Error(w, "no", http.StatusInternalServerError)
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
rb.FailMethod("editMessageText", http.StatusInternalServerError, "") rb.FailMethod("editMessageText", http.StatusInternalServerError, "")
@@ -254,7 +254,7 @@ func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) {
_, _ = w.Write([]byte("GIF89a-remote")) _, _ = w.Write([]byte("GIF89a-remote"))
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
rb.FailMethod("sendAnimation", http.StatusInternalServerError, "") rb.FailMethod("sendAnimation", http.StatusInternalServerError, "")
@@ -283,7 +283,7 @@ func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) {
_, _ = w.Write([]byte("GIF89a-remote")) _, _ = w.Write([]byte("GIF89a-remote"))
})) }))
defer server.Close() defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") t.Setenv(rendererURLEnv, server.URL)
rb := installRandom(t, 999) rb := installRandom(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice") 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 // Package random groups the commands that pick one option at random: /random
// (plain text pick), /wheelofnames (wheel GIF when a renderer is configured), // (plain text pick), /wheelofnames (wheel GIF when a renderer is configured),
// /gacha (card-pack wish MP4), and the unlisted /genshin (Genshin-style meteor // /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. // fall back to a text reply without it.
package random 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" "testing"
) )
func TestWheelAPIClient_RenderValidRequest(t *testing.T) { func TestRendererClient_RenderValidRequest(t *testing.T) {
var got wheelAPIRequest var got wheelRenderRequest
var gotAccept string var gotAccept string
var gotContentType string var gotContentType string
var gotMethod string var gotMethod string
@@ -30,13 +30,13 @@ func TestWheelAPIClient_RenderValidRequest(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
client := wheelAPIClient{ client := rendererClient{
HTTP: server.Client(), HTTP: server.Client(),
URL: server.URL + "/api/gif", 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 { if err != nil {
t.Fatalf("Render: %v", err) t.Fatalf("RenderWheel: %v", err)
} }
if !bytes.Equal(data, []byte("GIF89a-remote")) { if !bytes.Equal(data, []byte("GIF89a-remote")) {
t.Fatalf("data = %q, want remote GIF bytes", data) 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. // 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 var gotAuthorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuthorization = r.Header.Get("Authorization") gotAuthorization = r.Header.Get("Authorization")
@@ -72,45 +72,45 @@ func TestWheelAPIClient_RenderSendsNoAuthorization(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} client := rendererClient{HTTP: server.Client(), BaseURL: server.URL}
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err != nil { if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err != nil {
t.Fatalf("Render: %v", err) t.Fatalf("RenderWheel: %v", err)
} }
if gotAuthorization != "" { if gotAuthorization != "" {
t.Fatalf("Authorization = %q, want empty", gotAuthorization) t.Fatalf("Authorization = %q, want empty", gotAuthorization)
} }
} }
func TestWheelAPIClient_RenderNotConfigured(t *testing.T) { func TestRendererClient_RenderNotConfigured(t *testing.T) {
client := wheelAPIClient{} client := rendererClient{}
_, err := client.Render(context.Background(), []string{"alice"}, 0) _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0)
if !errors.Is(err, errWheelAPINotConfigured) { if !errors.Is(err, errRendererNotConfigured) {
t.Fatalf("Render error = %v, want errWheelAPINotConfigured", err) t.Fatalf("RenderWheel error = %v, want errRendererNotConfigured", err)
} }
} }
func TestWheelAPIClient_RenderRejectsInvalidInput(t *testing.T) { func TestRendererClient_RenderRejectsInvalidInput(t *testing.T) {
client := wheelAPIClient{URL: "https://example.com/api/gif"} client := rendererClient{BaseURL: "https://example.com"}
for _, tc := range []struct { for _, tc := range []struct {
name string name string
url string url string
options []string options []string
winner int winner int
}{ }{
{name: "bad scheme", url: "ftp://example.com/api/gif", options: []string{"alice"}, winner: 0}, {name: "bad scheme", url: "ftp://example.com", options: []string{"alice"}, winner: 0},
{name: "empty options", url: "https://example.com/api/gif", options: nil, winner: 0}, {name: "empty options", url: "https://example.com", options: nil, winner: 0},
{name: "winner out of range", url: "https://example.com/api/gif", options: []string{"alice"}, winner: 1}, {name: "winner out of range", url: "https://example.com", options: []string{"alice"}, winner: 1},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
client.URL = tc.url client.BaseURL = tc.url
if _, err := client.Render(context.Background(), tc.options, tc.winner); err == nil { if _, err := client.RenderWheel(context.Background(), tc.options, tc.winner); err == nil {
t.Fatalf("Render returned nil error") t.Fatalf("RenderWheel returned nil error")
} }
}) })
} }
} }
func TestWheelAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) { func TestRendererClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
status int status int
@@ -131,35 +131,35 @@ func TestWheelAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} client := rendererClient{HTTP: server.Client(), BaseURL: server.URL}
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil { if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err == nil {
t.Fatalf("Render returned nil error") 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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/gif") 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() defer server.Close()
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"} client := rendererClient{HTTP: server.Client(), BaseURL: server.URL}
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil { if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err == nil {
t.Fatalf("Render returned nil error") t.Fatalf("RenderWheel returned nil error")
} }
} }
func TestWheelAPIClient_DefaultHTTPClientHasTimeout(t *testing.T) { func TestRendererClient_DefaultHTTPClientHasTimeout(t *testing.T) {
client := wheelAPIClient{} client := rendererClient{}
if got := client.httpClient().Timeout; got != wheelRemoteTimeout { if got := client.httpClient().Timeout; got != rendererTimeout {
t.Fatalf("timeout = %s, want %s", got, wheelRemoteTimeout) t.Fatalf("timeout = %s, want %s", got, rendererTimeout)
} }
} }
func assertWheelRemoteDefaults(t *testing.T, got wheelAPIRequest) { func assertWheelRemoteDefaults(t *testing.T, got wheelRenderRequest) {
t.Helper() t.Helper()
if got.DurationMs != wheelRemoteDurationMs { if got.DurationMs != wheelRemoteDurationMs {
t.Fatalf("durationMs = %d, want %d", 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) placeholder := sendRenderPlaceholder(ctx, b, update.Message, wheelPlaceholder)
animation, err := renderWheelOfNamesAnimation(ctx, options, winner) animation, err := renderWheelOfNamesAnimation(ctx, options, winner)
if err != nil { if err != nil {
if !errors.Is(err, errWheelAPINotConfigured) { if !errors.Is(err, errRendererNotConfigured) {
log.Warn("wheelofnames remote render failed", "err", err) log.Warn("wheelofnames remote render failed", "err", err)
} }
return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, options[winner]) 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 // is immediate, and a placeholder would only flash. A failed placeholder is
// non-fatal — the pick still resolves, just without the holding message. // 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 { 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 return 0
} }
id, err := chathelper.SendText(ctx, b, msg, text) id, err := chathelper.SendText(ctx, b, msg, text)
if err != nil { 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 0
} }
return id return id
@@ -101,7 +101,7 @@ func replaceWheelPlaceholder(ctx context.Context, b *bot.Bot, msg *models.Messag
if err == nil { if err == nil {
return 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) 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. dependencies, and FFmpeg/compositor support.
```sh ```sh
docker build -t wheelofnames . docker build -t miti99bot-renderer .
docker run --rm -p 3000:3000 wheelofnames docker run --rm -p 3000:3000 miti99bot-renderer
``` ```
Recommended starting resources: 1-2 vCPU and 1-2 GB RAM, with Recommended starting resources: 1-2 vCPU and 1-2 GB RAM, with
+2 -3
View File
@@ -1,7 +1,7 @@
# miti99bot Integration # miti99bot Integration
miti99bot's `/wheelofnames` renders its wheel with this service, configured 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 the compose network, so requests carry no credentials. Winner handling stays
explicit: explicit:
@@ -43,8 +43,7 @@ winner selection is used.
## Gacha ## Gacha
`/gacha` in miti99bot calls `POST /api/gacha` on the same service. `/gacha` in miti99bot calls `POST /api/gacha` on the same service.
The bot derives the endpoint from `WHEELOFNAMES_API_URL` by replacing its last The bot appends the route to `RENDERER_URL`, picks the result and rarity
path segment (`/api/gif` becomes `/api/gacha`), picks the result and rarity
itself, and sends the MP4 with `sendAnimation`. On any failure it falls back to itself, and sends the MP4 with `sendAnimation`. On any failure it falls back to
a text reply. a text reply.