mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
2ec4a2b371
commit
277c913f71
15 files changed
+302
-301
No files matched your search
+5
-5
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+14
-30
@@ -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,
|
||||||
+14
-13
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+38
-38
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user