mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-19 02:14:57 +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
@@ -100,7 +100,7 @@ func newGachaCommand(name string, visibility modules.Visibility, description, us
|
||||
placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder)
|
||||
animation, err := renderGachaAnimation(ctx, style, options[winner].Label, options[winner].Rarity)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errWheelAPINotConfigured) {
|
||||
if !errors.Is(err, errRendererNotConfigured) {
|
||||
log.Warn("gacha remote render failed", "command", name, "err", err)
|
||||
}
|
||||
return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner])
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestGacha_NotConfiguredRepliesWithStars(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
|
||||
var got gachaAPIRequest
|
||||
var got gachaRenderRequest
|
||||
var gotPath, gotAuthorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
@@ -66,7 +66,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
|
||||
_, _ = w.Write(mp4Bytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 4* Pho"))
|
||||
@@ -77,7 +77,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
|
||||
if gotAuthorization != "" {
|
||||
t.Fatalf("Authorization = %q, want none", gotAuthorization)
|
||||
}
|
||||
want := gachaAPIRequest{Label: "Pho", Rarity: 4, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}
|
||||
want := gachaRenderRequest{Label: "Pho", Rarity: 4, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}
|
||||
if got != want {
|
||||
t.Fatalf("request = %+v, want %+v", got, want)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestGacha_RemoteFailureFallsBackToText(t *testing.T) {
|
||||
http.Error(w, "no", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice"))
|
||||
@@ -131,7 +131,7 @@ func TestGacha_SendAnimationFailureFallsBackToText(t *testing.T) {
|
||||
_, _ = w.Write(mp4Bytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
rb.FailMethod("sendAnimation", http.StatusInternalServerError, "")
|
||||
@@ -149,7 +149,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) {
|
||||
_, _ = w.Write(mp4Bytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
update := testutil.NewSupergroupMessage(-100, 7, "/gacha 3* Rice")
|
||||
@@ -175,7 +175,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) {
|
||||
|
||||
func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) {
|
||||
var gotPath string
|
||||
var got gachaAPIRequest
|
||||
var got gachaRenderRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
@@ -185,7 +185,7 @@ func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) {
|
||||
_, _ = w.Write(mp4Bytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
// 7 is neither the owner (999) nor an admin.
|
||||
rb := installRandom(t, 999)
|
||||
@@ -194,7 +194,7 @@ func TestGenshin_UsesGenshinRendererAndAnyoneCanRunIt(t *testing.T) {
|
||||
if gotPath != "/api/genshin" {
|
||||
t.Fatalf("path = %q, want /api/genshin", gotPath)
|
||||
}
|
||||
if want := (gachaAPIRequest{Label: "Bún bò", Rarity: 5, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}); got != want {
|
||||
if want := (gachaRenderRequest{Label: "Bún bò", Rarity: 5, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}); got != want {
|
||||
t.Fatalf("request = %+v, want %+v", got, want)
|
||||
}
|
||||
var animation *testutil.SentCall
|
||||
|
||||
+14
-30
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,8 +14,8 @@ const (
|
||||
gachaRemoteHeight = 360
|
||||
)
|
||||
|
||||
// gachaStyle is one wish animation the renderer serves: its path next to
|
||||
// /api/gif, the clip length in seconds, and the frame size it renders for a
|
||||
// gachaStyle is one wish animation the renderer serves: its route name under
|
||||
// /api, the clip length in seconds, and the frame size it renders for a
|
||||
// gachaRemoteWidth request.
|
||||
type gachaStyle struct {
|
||||
Path string
|
||||
@@ -31,49 +30,34 @@ var (
|
||||
gachaStyleGenshin = gachaStyle{Path: "genshin", Duration: 7, Width: gachaRemoteWidth, Height: gachaRemoteHeight}
|
||||
)
|
||||
|
||||
type gachaAPIRequest struct {
|
||||
type gachaRenderRequest struct {
|
||||
Label string `json:"label"`
|
||||
Rarity int `json:"rarity"`
|
||||
FPS int `json:"fps"`
|
||||
Width int `json:"width"`
|
||||
}
|
||||
|
||||
// gachaAPIEndpoint derives a wish renderer from the configured wheel
|
||||
// endpoint: the same service serves /api/gacha and /api/genshin next to
|
||||
// /api/gif, so the last path segment is swapped and no second URL needs
|
||||
// configuring.
|
||||
func gachaAPIEndpoint(rawURL, path string) (*url.URL, error) {
|
||||
endpoint, err := wheelAPIEndpoint(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := *endpoint
|
||||
base.Path = strings.TrimSuffix(base.Path, "/")
|
||||
base.RawPath = ""
|
||||
return base.ResolveReference(&url.URL{Path: path}), nil
|
||||
}
|
||||
|
||||
// RenderGacha returns the MP4 wish animation at path revealing label at
|
||||
// RenderGacha returns the MP4 wish animation named path revealing label at
|
||||
// rarity stars.
|
||||
func (c wheelAPIClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) {
|
||||
endpoint, err := gachaAPIEndpoint(c.URL, path)
|
||||
func (c rendererClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) {
|
||||
endpoint, err := c.endpoint(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(label) == "" {
|
||||
return nil, fmt.Errorf("gacha api label empty")
|
||||
return nil, fmt.Errorf("renderer gacha label empty")
|
||||
}
|
||||
if rarity < gachaMinRarity || rarity > gachaMaxRarity {
|
||||
return nil, fmt.Errorf("gacha api rarity %d out of range", rarity)
|
||||
return nil, fmt.Errorf("renderer gacha rarity %d out of range", rarity)
|
||||
}
|
||||
body, err := json.Marshal(gachaAPIRequest{
|
||||
body, err := json.Marshal(gachaRenderRequest{
|
||||
Label: label,
|
||||
Rarity: rarity,
|
||||
FPS: gachaRemoteFPS,
|
||||
Width: gachaRemoteWidth,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gacha api request encode failed: %w", err)
|
||||
return nil, fmt.Errorf("renderer gacha request encode failed: %w", err)
|
||||
}
|
||||
return c.post(ctx, endpoint, body, "video/mp4", isMP4)
|
||||
}
|
||||
@@ -83,12 +67,12 @@ func isMP4(data []byte) bool {
|
||||
return len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp"))
|
||||
}
|
||||
|
||||
func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (wheelAnimation, error) {
|
||||
data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, style.Path, label, rarity)
|
||||
func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (renderedAnimation, error) {
|
||||
data, err := newRendererClientFromEnv().RenderGacha(ctx, style.Path, label, rarity)
|
||||
if err != nil {
|
||||
return wheelAnimation{}, err
|
||||
return renderedAnimation{}, err
|
||||
}
|
||||
return wheelAnimation{
|
||||
return renderedAnimation{
|
||||
Data: data,
|
||||
Duration: style.Duration,
|
||||
Width: style.Width,
|
||||
+14
-13
@@ -7,27 +7,28 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) {
|
||||
func TestRendererClient_EndpointJoinsAPIRoute(t *testing.T) {
|
||||
for raw, want := range map[string]string{
|
||||
"https://render.example/api/gif": "https://render.example/api/gacha",
|
||||
"https://render.example/api/gif/": "https://render.example/api/gacha",
|
||||
"http://10.0.0.5:3000/api/gif": "http://10.0.0.5:3000/api/gacha",
|
||||
"http://renderer:3000": "http://renderer:3000/api/gacha",
|
||||
"http://renderer:3000/": "http://renderer:3000/api/gacha",
|
||||
"https://render.example/proxied": "https://render.example/proxied/api/gacha",
|
||||
"https://render.example/proxied/": "https://render.example/proxied/api/gacha",
|
||||
} {
|
||||
got, err := gachaAPIEndpoint(raw, "gacha")
|
||||
got, err := rendererClient{BaseURL: raw}.endpoint("gacha")
|
||||
if err != nil {
|
||||
t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err)
|
||||
t.Fatalf("endpoint(%q): %v", raw, err)
|
||||
}
|
||||
if got.String() != want {
|
||||
t.Errorf("gachaAPIEndpoint(%q) = %q, want %q", raw, got, want)
|
||||
t.Errorf("endpoint(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := gachaAPIEndpoint("", "gacha"); err != errWheelAPINotConfigured {
|
||||
t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err)
|
||||
if _, err := (rendererClient{}).endpoint("gacha"); err != errRendererNotConfigured {
|
||||
t.Fatalf("empty url err = %v, want errRendererNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) {
|
||||
client := wheelAPIClient{URL: "https://example.com/api/gif"}
|
||||
func TestRendererClient_RenderGachaRejectsInvalidInput(t *testing.T) {
|
||||
client := rendererClient{BaseURL: "https://example.com"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
label string
|
||||
@@ -45,7 +46,7 @@ func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) {
|
||||
func TestRendererClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
@@ -65,7 +66,7 @@ func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
|
||||
client := rendererClient{HTTP: server.Client(), BaseURL: server.URL}
|
||||
if _, err := client.RenderGacha(context.Background(), "gacha", "a", 3); err == nil {
|
||||
t.Fatal("RenderGacha returned nil error")
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestWheelOfNames_ResultCaptionPadsShortWinnerToLongestOption(t *testing.T)
|
||||
}
|
||||
|
||||
func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) {
|
||||
var got wheelAPIRequest
|
||||
var got wheelRenderRequest
|
||||
var gotAuthorization string
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -131,7 +131,7 @@ func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) {
|
||||
_, _ = w.Write([]byte("GIF89a-remote"))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol"))
|
||||
@@ -191,7 +191,7 @@ func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) {
|
||||
http.Error(w, "no", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
|
||||
@@ -221,7 +221,7 @@ func TestWheelOfNames_PlaceholderEditFailureFallsBackToReply(t *testing.T) {
|
||||
http.Error(w, "no", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
rb.FailMethod("editMessageText", http.StatusInternalServerError, "")
|
||||
@@ -254,7 +254,7 @@ func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) {
|
||||
_, _ = w.Write([]byte("GIF89a-remote"))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
rb.FailMethod("sendAnimation", http.StatusInternalServerError, "")
|
||||
@@ -283,7 +283,7 @@ func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) {
|
||||
_, _ = w.Write([]byte("GIF89a-remote"))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
|
||||
t.Setenv(rendererURLEnv, server.URL)
|
||||
|
||||
rb := installRandom(t, 999)
|
||||
update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package random groups the commands that pick one option at random: /random
|
||||
// (plain text pick), /wheelofnames (wheel GIF when a renderer is configured),
|
||||
// /gacha (card-pack wish MP4), and the unlisted /genshin (Genshin-style meteor
|
||||
// wish MP4). The animated commands share the optional wheelofnames renderer and
|
||||
// wish MP4). The animated commands share the optional renderer service and
|
||||
// fall back to a text reply without it.
|
||||
package random
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
func TestWheelAPIClient_RenderValidRequest(t *testing.T) {
|
||||
var got wheelAPIRequest
|
||||
func TestRendererClient_RenderValidRequest(t *testing.T) {
|
||||
var got wheelRenderRequest
|
||||
var gotAccept string
|
||||
var gotContentType string
|
||||
var gotMethod string
|
||||
@@ -30,13 +30,13 @@ func TestWheelAPIClient_RenderValidRequest(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := wheelAPIClient{
|
||||
HTTP: server.Client(),
|
||||
URL: server.URL + "/api/gif",
|
||||
client := rendererClient{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
}
|
||||
data, err := client.Render(context.Background(), []string{"alice", "bob", "carol"}, 1)
|
||||
data, err := client.RenderWheel(context.Background(), []string{"alice", "bob", "carol"}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
t.Fatalf("RenderWheel: %v", err)
|
||||
}
|
||||
if !bytes.Equal(data, []byte("GIF89a-remote")) {
|
||||
t.Fatalf("data = %q, want remote GIF bytes", data)
|
||||
@@ -63,7 +63,7 @@ func TestWheelAPIClient_RenderValidRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
// The renderer is internal to the compose network; the bot sends no credentials.
|
||||
func TestWheelAPIClient_RenderSendsNoAuthorization(t *testing.T) {
|
||||
func TestRendererClient_RenderSendsNoAuthorization(t *testing.T) {
|
||||
var gotAuthorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuthorization = r.Header.Get("Authorization")
|
||||
@@ -72,45 +72,45 @@ func TestWheelAPIClient_RenderSendsNoAuthorization(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
|
||||
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
client := rendererClient{HTTP: server.Client(), BaseURL: server.URL}
|
||||
if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err != nil {
|
||||
t.Fatalf("RenderWheel: %v", err)
|
||||
}
|
||||
if gotAuthorization != "" {
|
||||
t.Fatalf("Authorization = %q, want empty", gotAuthorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelAPIClient_RenderNotConfigured(t *testing.T) {
|
||||
client := wheelAPIClient{}
|
||||
_, err := client.Render(context.Background(), []string{"alice"}, 0)
|
||||
if !errors.Is(err, errWheelAPINotConfigured) {
|
||||
t.Fatalf("Render error = %v, want errWheelAPINotConfigured", err)
|
||||
func TestRendererClient_RenderNotConfigured(t *testing.T) {
|
||||
client := rendererClient{}
|
||||
_, err := client.RenderWheel(context.Background(), []string{"alice"}, 0)
|
||||
if !errors.Is(err, errRendererNotConfigured) {
|
||||
t.Fatalf("RenderWheel error = %v, want errRendererNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelAPIClient_RenderRejectsInvalidInput(t *testing.T) {
|
||||
client := wheelAPIClient{URL: "https://example.com/api/gif"}
|
||||
func TestRendererClient_RenderRejectsInvalidInput(t *testing.T) {
|
||||
client := rendererClient{BaseURL: "https://example.com"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
url string
|
||||
options []string
|
||||
winner int
|
||||
}{
|
||||
{name: "bad scheme", url: "ftp://example.com/api/gif", options: []string{"alice"}, winner: 0},
|
||||
{name: "empty options", url: "https://example.com/api/gif", options: nil, winner: 0},
|
||||
{name: "winner out of range", url: "https://example.com/api/gif", options: []string{"alice"}, winner: 1},
|
||||
{name: "bad scheme", url: "ftp://example.com", options: []string{"alice"}, winner: 0},
|
||||
{name: "empty options", url: "https://example.com", options: nil, winner: 0},
|
||||
{name: "winner out of range", url: "https://example.com", options: []string{"alice"}, winner: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client.URL = tc.url
|
||||
if _, err := client.Render(context.Background(), tc.options, tc.winner); err == nil {
|
||||
t.Fatalf("Render returned nil error")
|
||||
client.BaseURL = tc.url
|
||||
if _, err := client.RenderWheel(context.Background(), tc.options, tc.winner); err == nil {
|
||||
t.Fatalf("RenderWheel returned nil error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
|
||||
func TestRendererClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
@@ -131,35 +131,35 @@ func TestWheelAPIClient_RenderReturnsErrorsForBadResponses(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
|
||||
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil {
|
||||
t.Fatalf("Render returned nil error")
|
||||
client := rendererClient{HTTP: server.Client(), BaseURL: server.URL}
|
||||
if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err == nil {
|
||||
t.Fatalf("RenderWheel returned nil error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelAPIClient_RenderRejectsOversizedResponse(t *testing.T) {
|
||||
func TestRendererClient_RenderRejectsOversizedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
_, _ = w.Write(bytes.Repeat([]byte("a"), int(wheelRemoteMaxBytes)+1))
|
||||
_, _ = w.Write(bytes.Repeat([]byte("a"), int(rendererMaxBytes)+1))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
|
||||
if _, err := client.Render(context.Background(), []string{"alice"}, 0); err == nil {
|
||||
t.Fatalf("Render returned nil error")
|
||||
client := rendererClient{HTTP: server.Client(), BaseURL: server.URL}
|
||||
if _, err := client.RenderWheel(context.Background(), []string{"alice"}, 0); err == nil {
|
||||
t.Fatalf("RenderWheel returned nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelAPIClient_DefaultHTTPClientHasTimeout(t *testing.T) {
|
||||
client := wheelAPIClient{}
|
||||
if got := client.httpClient().Timeout; got != wheelRemoteTimeout {
|
||||
t.Fatalf("timeout = %s, want %s", got, wheelRemoteTimeout)
|
||||
func TestRendererClient_DefaultHTTPClientHasTimeout(t *testing.T) {
|
||||
client := rendererClient{}
|
||||
if got := client.httpClient().Timeout; got != rendererTimeout {
|
||||
t.Fatalf("timeout = %s, want %s", got, rendererTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWheelRemoteDefaults(t *testing.T, got wheelAPIRequest) {
|
||||
func assertWheelRemoteDefaults(t *testing.T, got wheelRenderRequest) {
|
||||
t.Helper()
|
||||
if got.DurationMs != wheelRemoteDurationMs {
|
||||
t.Fatalf("durationMs = %d, want %d", got.DurationMs, wheelRemoteDurationMs)
|
||||
@@ -1,179 +0,0 @@
|
||||
package random
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// The standard renderer is the renderer/ service in this repository, wired
|
||||
// in by compose.yml and reachable only on the compose network, so requests
|
||||
// carry no credentials. Operators may point this to any service that
|
||||
// implements the same /api/gif contract.
|
||||
wheelOfNamesAPIURLEnv = "WHEELOFNAMES_API_URL"
|
||||
|
||||
wheelRemoteDurationMs = 6000
|
||||
wheelRemoteHoldMs = 1000
|
||||
wheelRemoteFPS = 20
|
||||
wheelRemoteSize = 512
|
||||
wheelRemoteTheme = "classic"
|
||||
wheelRemoteDuration = (wheelRemoteDurationMs + wheelRemoteHoldMs) / 1000
|
||||
wheelRemoteMaxBytes = 12 << 20
|
||||
wheelRemoteTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var errWheelAPINotConfigured = errors.New("wheelofnames api not configured")
|
||||
|
||||
type wheelAPIClient struct {
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
}
|
||||
|
||||
type wheelAPIRequest struct {
|
||||
Options []string `json:"options"`
|
||||
WinnerIndex int `json:"winnerIndex"`
|
||||
DurationMs int `json:"durationMs"`
|
||||
HoldMs int `json:"holdMs"`
|
||||
FPS int `json:"fps"`
|
||||
Size int `json:"size"`
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
|
||||
type wheelAnimation struct {
|
||||
Data []byte
|
||||
Duration int
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
func newWheelAPIClientFromEnv() wheelAPIClient {
|
||||
return wheelAPIClient{
|
||||
URL: strings.TrimSpace(os.Getenv(wheelOfNamesAPIURLEnv)),
|
||||
}
|
||||
}
|
||||
|
||||
func (c wheelAPIClient) Render(ctx context.Context, options []string, winner int) ([]byte, error) {
|
||||
endpoint, err := wheelAPIEndpoint(c.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil, fmt.Errorf("wheelofnames api options empty")
|
||||
}
|
||||
if winner < 0 || winner >= len(options) {
|
||||
return nil, fmt.Errorf("wheelofnames api winner index %d out of range %d", winner, len(options))
|
||||
}
|
||||
|
||||
body, err := json.Marshal(wheelAPIRequest{
|
||||
Options: options,
|
||||
WinnerIndex: winner,
|
||||
DurationMs: wheelRemoteDurationMs,
|
||||
HoldMs: wheelRemoteHoldMs,
|
||||
FPS: wheelRemoteFPS,
|
||||
Size: wheelRemoteSize,
|
||||
Theme: wheelRemoteTheme,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wheelofnames api request encode failed: %w", err)
|
||||
}
|
||||
|
||||
return c.post(ctx, endpoint, body, "image/gif", isWheelGIF)
|
||||
}
|
||||
|
||||
// post sends one JSON render request to endpoint and returns the response
|
||||
// body once it is a 2xx of the expected media type that passes isValid. The
|
||||
// gif and gacha renderers share it because they live on the same service.
|
||||
func (c wheelAPIClient) post(ctx context.Context, endpoint *url.URL, body []byte, mediaType string, isValid func([]byte) bool) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wheelofnames api request build failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", mediaType)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("wheelofnames api request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("wheelofnames api status %d", resp.StatusCode)
|
||||
}
|
||||
if err := requireContentType(resp.Header.Get("Content-Type"), mediaType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, wheelRemoteMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wheelofnames api response read failed: %w", err)
|
||||
}
|
||||
if len(data) > wheelRemoteMaxBytes {
|
||||
return nil, fmt.Errorf("wheelofnames api response too large")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("wheelofnames api response empty")
|
||||
}
|
||||
if !isValid(data) {
|
||||
return nil, fmt.Errorf("wheelofnames api response is not %s", mediaType)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c wheelAPIClient) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: wheelRemoteTimeout}
|
||||
}
|
||||
|
||||
func wheelAPIEndpoint(rawURL string) (*url.URL, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, errWheelAPINotConfigured
|
||||
}
|
||||
endpoint, err := url.Parse(rawURL)
|
||||
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
|
||||
return nil, fmt.Errorf("wheelofnames api url invalid")
|
||||
}
|
||||
if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
|
||||
return nil, fmt.Errorf("wheelofnames api url scheme %q unsupported", endpoint.Scheme)
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
func requireContentType(contentType, want string) error {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType != want {
|
||||
return fmt.Errorf("wheelofnames api content type %q unsupported", contentType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWheelGIF(data []byte) bool {
|
||||
return bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a"))
|
||||
}
|
||||
|
||||
func renderWheelOfNamesAnimation(ctx context.Context, options []string, winner int) (wheelAnimation, error) {
|
||||
client := newWheelAPIClientFromEnv()
|
||||
data, err := client.Render(ctx, options, winner)
|
||||
if err != nil {
|
||||
return wheelAnimation{}, err
|
||||
}
|
||||
return wheelAnimation{
|
||||
Data: data,
|
||||
Duration: wheelRemoteDuration,
|
||||
Width: wheelRemoteSize,
|
||||
Height: wheelRemoteSize,
|
||||
}, nil
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func wheelOfNamesCommand() modules.Command {
|
||||
placeholder := sendRenderPlaceholder(ctx, b, update.Message, wheelPlaceholder)
|
||||
animation, err := renderWheelOfNamesAnimation(ctx, options, winner)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errWheelAPINotConfigured) {
|
||||
if !errors.Is(err, errRendererNotConfigured) {
|
||||
log.Warn("wheelofnames remote render failed", "err", err)
|
||||
}
|
||||
return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, options[winner])
|
||||
@@ -81,12 +81,12 @@ const wheelPlaceholder = "Spinning..."
|
||||
// is immediate, and a placeholder would only flash. A failed placeholder is
|
||||
// non-fatal — the pick still resolves, just without the holding message.
|
||||
func sendRenderPlaceholder(ctx context.Context, b *bot.Bot, msg *models.Message, text string) int {
|
||||
if _, err := wheelAPIEndpoint(newWheelAPIClientFromEnv().URL); err != nil {
|
||||
if _, err := rendererBaseURL(newRendererClientFromEnv().BaseURL); err != nil {
|
||||
return 0
|
||||
}
|
||||
id, err := chathelper.SendText(ctx, b, msg, text)
|
||||
if err != nil {
|
||||
log.Warn("wheelofnames placeholder send failed", "chat", msg.Chat.ID, "err", err)
|
||||
log.Warn("render placeholder send failed", "chat", msg.Chat.ID, "err", err)
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
@@ -101,7 +101,7 @@ func replaceWheelPlaceholder(ctx context.Context, b *bot.Bot, msg *models.Messag
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
log.Warn("wheelofnames placeholder edit failed", "chat", msg.Chat.ID, "err", err)
|
||||
log.Warn("render placeholder edit failed", "chat", msg.Chat.ID, "err", err)
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, winner)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user