refactor!: move every config default into compose.yml

compose.yml now holds each default as ${VAR:-default}, and the code keeps
no fallback values. The bot stops at startup on a missing or invalid
PORT or LOG_LEVEL, /addsticker refuses without STICKER_PACK_NAME, and the
renderer refuses to start until every RENDERER_* setting is a valid
value, listing each problem. Settings whose empty value means none or
all (MODULES, OWNER_ID, ADMIN_IDS, the API tokens) use ${VAR:-}.

The renderer's npm start and dev load .env when present, so a local run
works from a copy of .env.example.

BREAKING CHANGE: running outside compose now requires LOG_LEVEL and PORT
for the bot, STICKER_PACK_NAME for /addsticker, and every RENDERER_*
variable for the renderer.
This commit is contained in:
tiennm99 committed 2026-10-03 12:38:39 +07:00
1 parent 4f8c01dae7
commit 153fc211cf
17 files changed
+208 -94

No files matched your search

+10 -3
View File
@@ -25,7 +25,8 @@ ADMIN_IDS=
# MUST end in _by_<this bot username>: Telegram requires that suffix on sets a # MUST end in _by_<this bot username>: Telegram requires that suffix on sets a
# bot creates and refuses to edit sets it did not create, so the suffix is what # bot creates and refuses to edit sets it did not create, so the suffix is what
# proves the pack is manageable. Created automatically, owned by OWNER_ID, on # proves the pack is manageable. Created automatically, owned by OWNER_ID, on
# the first /addsticker if it does not exist yet. Unset = the default below. # the first /addsticker if it does not exist yet. Required by /addsticker:
# the code has no fallback (compose.yml supplies this value as its default).
STICKER_PACK_NAME=miti99_by_miti99bot STICKER_PACK_NAME=miti99_by_miti99bot
# SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM. # SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM.
@@ -44,10 +45,16 @@ LOL_PANDASCORE_TOKEN=
# only when running the bot outside compose. Leave blank to fall back to text. # only when running the bot outside compose. Leave blank to fall back to text.
RENDERER_URL= RENDERER_URL=
# ============================ Runtime =============================
# Required: the code has no fallback. compose.yml supplies these values as its
# defaults; set them yourself when running outside compose.
# Log level: debug, info, warn, or error.
LOG_LEVEL=info
# Internal health server port.
PORT=8080
# ====================== Leave UNSET on self-host ================== # ====================== Leave UNSET on self-host ==================
# Defaults are correct for self-host:
# KV_PROVIDER — auto-selects mongodb because MONGO_URL is set # KV_PROVIDER — auto-selects mongodb because MONGO_URL is set
# PORT — defaults to 8080 (internal health server)
# TELEGRAM_WEBHOOK_SECRET — long polling has no webhook # TELEGRAM_WEBHOOK_SECRET — long polling has no webhook
# GOLD_VNAPP_API_KEY — gold module auto-fetches + caches the key to Mongo # GOLD_VNAPP_API_KEY — gold module auto-fetches + caches the key to Mongo
# Stock/coin/gold URL env overrides are not supported; modules use coded # Stock/coin/gold URL env overrides are not supported; modules use coded
+7
View File
@@ -262,6 +262,8 @@ shell, then run the server with Go:
```powershell ```powershell
# PowerShell # PowerShell
$env:TELEGRAM_BOT_TOKEN = "…" $env:TELEGRAM_BOT_TOKEN = "…"
$env:LOG_LEVEL = "info"
$env:PORT = "8080"
$env:MODULES = "" $env:MODULES = ""
go run ./cmd/server go run ./cmd/server
``` ```
@@ -269,10 +271,15 @@ go run ./cmd/server
```sh ```sh
# POSIX shells (Linux/macOS) # POSIX shells (Linux/macOS)
export TELEGRAM_BOT_TOKEN="…" export TELEGRAM_BOT_TOKEN="…"
export LOG_LEVEL=info
export PORT=8080
export MODULES="" export MODULES=""
go run ./cmd/server go run ./cmd/server
``` ```
The code has no default values: `compose.yml` supplies them, so a local run
sets `LOG_LEVEL` and `PORT` itself (and `STICKER_PACK_NAME` for `/addsticker`).
The bot uses long polling, so a local run talks to Telegram directly — no The bot uses long polling, so a local run talks to Telegram directly — no
`ngrok` or public URL. The server clears any existing webhook on startup. The `ngrok` or public URL. The server clears any existing webhook on startup. The
dev bot is created manually; its token is injected through the environment. dev bot is created manually; its token is injected through the environment.
+9 -7
View File
@@ -368,9 +368,9 @@ type config struct {
MongoDatabase string // required when KVProvider=mongodb MongoDatabase string // required when KVProvider=mongodb
} }
// loadConfig reads config from the environment. PORT defaults to 8080 and an // loadConfig reads config from the environment. compose.yml supplies every
// invalid PORT is fatal; malformed OWNER_ID / ADMIN_IDS entries are logged and // default, so the code keeps none: a missing or invalid PORT or LOG_LEVEL is
// ignored. // fatal. Malformed OWNER_ID / ADMIN_IDS entries are logged and ignored.
func loadConfig() config { func loadConfig() config {
envMap := make(map[string]string, len(os.Environ())) envMap := make(map[string]string, len(os.Environ()))
for _, kv := range os.Environ() { for _, kv := range os.Environ() {
@@ -378,15 +378,17 @@ func loadConfig() config {
envMap[kv[:eq]] = kv[eq+1:] envMap[kv[:eq]] = kv[eq+1:]
} }
} }
port := envMap["PORT"] level, err := log.ParseLevel(envMap["LOG_LEVEL"])
if port == "" { if err != nil {
port = "8080" log.Fatal("invalid LOG_LEVEL", "err", err)
} }
log.SetLevel(level)
port := strings.TrimSpace(envMap["PORT"])
// PORT must be a number in 0..65535. http.Server uses ":<port>" verbatim, // PORT must be a number in 0..65535. http.Server uses ":<port>" verbatim,
// so a junk value would otherwise surface only at ListenAndServe time; // so a junk value would otherwise surface only at ListenAndServe time;
// fail fast here instead. // fail fast here instead.
if n, err := strconv.Atoi(port); err != nil || n < 0 || n > 65535 { if n, err := strconv.Atoi(port); err != nil || n < 0 || n > 65535 {
log.Fatal("invalid PORT", "value", port) log.Fatal("missing or invalid PORT", "value", port)
} }
return config{ return config{
Port: port, Port: port,
+16 -16
View File
@@ -11,18 +11,18 @@ services:
MONGO_URL: ${MONGO_URL} # Atlas SRV string incl. credentials — SECRET MONGO_URL: ${MONGO_URL} # Atlas SRV string incl. credentials — SECRET
MONGO_DATABASE: ${MONGO_DATABASE} # e.g. miti99bot MONGO_DATABASE: ${MONGO_DATABASE} # e.g. miti99bot
# --- Access control (optional; empty uses the default) --- # --- Access control (optional; empty means none / all) ---
MODULES: ${MODULES} # CSV of modules; default: all modules MODULES: ${MODULES:-} # CSV of modules; empty = all modules
OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands; default: none OWNER_ID: ${OWNER_ID:-} # Telegram user id for owner-only commands; empty = none
ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids; default: none ADMIN_IDS: ${ADMIN_IDS:-} # CSV of admin Telegram user ids; empty = none
# --- Module settings (optional; empty uses the default) --- # --- Module settings (optional) ---
LOL_PANDASCORE_TOKEN: ${LOL_PANDASCORE_TOKEN} # PandaScore token — SECRET; default: none (/lol* fetches fail) LOL_PANDASCORE_TOKEN: ${LOL_PANDASCORE_TOKEN:-} # PandaScore token — SECRET; empty = /lol* fetches fail
GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY} # VNAppMob key — SECRET; default: fetched and cached in Mongo GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY:-} # VNAppMob key — SECRET; empty = fetched and cached in Mongo
STICKER_PACK_NAME: ${STICKER_PACK_NAME} # /addsticker set; default: miti99_by_miti99bot STICKER_PACK_NAME: ${STICKER_PACK_NAME:-miti99_by_miti99bot} # /addsticker set; must end in _by_<bot username>
# --- Runtime (optional; empty uses the default) --- # --- Runtime (defaults below; the code has no fallback) ---
LOG_LEVEL: ${LOG_LEVEL} # debug|info|warn|error; default: info LOG_LEVEL: ${LOG_LEVEL:-info} # debug|info|warn|error
# --- Fixed by this stack (not Coolify settings) --- # --- Fixed by this stack (not Coolify settings) ---
# The bundled renderer service below draws /wheelofnames, /gacha and # The bundled renderer service below draws /wheelofnames, /gacha and
@@ -30,12 +30,12 @@ services:
# platform-level value cannot point the bot elsewhere; the bot appends # platform-level value cannot point the bot elsewhere; the bot appends
# each /api/<name> route itself. # each /api/<name> route itself.
RENDERER_URL: http://renderer:3000 RENDERER_URL: http://renderer:3000
PORT: "8080" # health server; keep in sync with the healthcheck below
# --- Deliberately not declared --- # --- Deliberately not declared ---
# SOURCE_COMMIT: Coolify provides it at runtime via its generated env # SOURCE_COMMIT: Coolify provides it at runtime via its generated env
# file; declaring it here with Compose interpolation can override the # file; declaring it here with Compose interpolation can override the
# runtime value with an empty string. # runtime value with an empty string.
# PORT: the health server defaults to 8080, which the healthcheck uses.
# KV_PROVIDER: storage auto-selects mongodb because MONGO_URL is set. # KV_PROVIDER: storage auto-selects mongodb because MONGO_URL is set.
# Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no public domain. # Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no public domain.
# Cron is in-process only — no CRON_MODE, no /cron route, no secret. # Cron is in-process only — no CRON_MODE, no /cron route, no secret.
@@ -70,11 +70,11 @@ services:
# Every renderer setting carries the RENDERER_ prefix so it reads as a # Every renderer setting carries the RENDERER_ prefix so it reads as a
# renderer setting and cannot clash with the bot's variables. # renderer setting and cannot clash with the bot's variables.
# --- Tuning (optional; empty uses the default) --- # --- Tuning (defaults below; the renderer has no fallback) ---
RENDERER_MAX_CONCURRENT_RENDERS: ${RENDERER_MAX_CONCURRENT_RENDERS} # default: 1 RENDERER_MAX_CONCURRENT_RENDERS: ${RENDERER_MAX_CONCURRENT_RENDERS:-1}
RENDERER_RENDER_TIMEOUT_MS: ${RENDERER_RENDER_TIMEOUT_MS} # default: 15000 (floor 7000) RENDERER_RENDER_TIMEOUT_MS: ${RENDERER_RENDER_TIMEOUT_MS:-15000} # floor 7000
RENDERER_MAX_OPTIONS: ${RENDERER_MAX_OPTIONS} # default: 32 wheel options RENDERER_MAX_OPTIONS: ${RENDERER_MAX_OPTIONS:-32} # wheel options
RENDERER_MAX_OPTION_CHARS: ${RENDERER_MAX_OPTION_CHARS} # default: 40 chars per option/label RENDERER_MAX_OPTION_CHARS: ${RENDERER_MAX_OPTION_CHARS:-40} # chars per option/label
# --- Fixed by this stack (not Coolify settings) --- # --- Fixed by this stack (not Coolify settings) ---
NODE_ENV: production NODE_ENV: production
+8 -3
View File
@@ -34,15 +34,20 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
| `MODULES` | optional | CSV; empty = all modules, including any added later | | `MODULES` | optional | CSV; empty = all modules, including any added later |
| `OWNER_ID` | optional | Telegram user id for owner-only commands, the deploy DM, and the `/addsticker` pack owner. Unset = owner-only commands are denied and `/addsticker` refuses | | `OWNER_ID` | optional | Telegram user id for owner-only commands, the deploy DM, and the `/addsticker` pack owner. Unset = owner-only commands are denied and `/addsticker` refuses |
| `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; `compose.yml` default `miti99_by_miti99bot`. See [sticker packs](sticker-packs.md) |
| `LOL_PANDASCORE_TOKEN` | optional | PandaScore API token for the lol module (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) | | `LOL_PANDASCORE_TOKEN` | optional | PandaScore API token for the lol module (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) |
| `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 | | `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`, `warn`, or `error`; `compose.yml` default `info`; logs are JSON on stdout |
| `GOLD_VNAPP_API_KEY` | optional | VNAppMob key — secret; empty = the gold module fetches one and caches it in MongoDB | | `GOLD_VNAPP_API_KEY` | optional | VNAppMob key — secret; empty = 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` |
| `PORT` | leave unset | health server port; default `8080` | | `PORT` | leave unset | health server port; fixed to `8080` by `compose.yml`, which the health check uses |
| `SOURCE_COMMIT` | never set | provided by Coolify at runtime for the deploy DM (see step 5 below) | | `SOURCE_COMMIT` | never set | provided by Coolify at runtime for the deploy DM (see step 5 below) |
Defaults live in `compose.yml` only (`${VAR:-default}`); the code has no
fallback values. `PORT`, `LOG_LEVEL`, and (for `/addsticker`)
`STICKER_PACK_NAME` must therefore be set when running outside compose — a
missing or invalid `PORT` or `LOG_LEVEL` stops the bot at startup.
Stock, coin, and gold provider URL overrides are not supported in runtime env; Stock, coin, and gold provider URL overrides are not supported in runtime env;
modules use coded defaults. There is no `TELEGRAM_WEBHOOK_SECRET`: long polling modules use coded defaults. There is no `TELEGRAM_WEBHOOK_SECRET`: long polling
has no webhook. has no webhook.
+3 -3
View File
@@ -22,14 +22,14 @@ Single-shot: one message replying to the media to add. No conversation state.
## Configuration ## Configuration
| Env | Default | Meaning | | Env | `compose.yml` default | Meaning |
|---|---|---| |---|---|---|
| `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to | | `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to; required — unset, `/addsticker` refuses |
| `OWNER_ID` | — | Must be the account that **owns** that set | | `OWNER_ID` | — | Must be the account that **owns** that set |
`OWNER_ID` is reused rather than given a sticker-specific twin because `OWNER_ID` is reused rather than given a sticker-specific twin because
`addStickerToSet` takes the **set owner's** user ID, not the caller's, and the `addStickerToSet` takes the **set owner's** user ID, not the caller's, and the
default pack belongs to the bot owner. Point `OWNER_ID` at the owning account if standard pack belongs to the bot owner. Point `OWNER_ID` at the owning account if
the configured pack belongs to someone else. the configured pack belongs to someone else.
The caller's identity is used nowhere. That is what makes the command stateless: The caller's identity is used nowhere. That is what makes the command stateless:
+22 -15
View File
@@ -19,36 +19,43 @@ package log
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"os" "os"
"strings" "strings"
) )
// defaultLogger is constructed at init from LOG_LEVEL. Tests can swap it via // level is the default logger's minimum level. It starts at Info so logging
// SetDefault — but the public Info/Warn/Error/Fatal helpers always read the // works before configuration is read (and in tests); SetLevel applies the
// current default so test substitutions take effect immediately. // configured LOG_LEVEL at startup.
var defaultLogger *slog.Logger var level = new(slog.LevelVar)
func init() { // defaultLogger writes JSON to stdout. Tests can swap it via SetDefault — but
defaultLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ // the public Info/Warn/Error/Fatal helpers always read the current default so
Level: parseLevel(os.Getenv("LOG_LEVEL")), // test substitutions take effect immediately.
})) var defaultLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
}
// parseLevel maps LOG_LEVEL env to a slog.Level. Unknown / empty → Info. // ParseLevel maps a LOG_LEVEL value to a slog.Level. There is no fallback: an
func parseLevel(s string) slog.Level { // empty or unknown value is an error, so a typo cannot silently change what
// gets logged.
func ParseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) { switch strings.ToLower(strings.TrimSpace(s)) {
case "debug": case "debug":
return slog.LevelDebug return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn", "warning": case "warn", "warning":
return slog.LevelWarn return slog.LevelWarn, nil
case "error": case "error":
return slog.LevelError return slog.LevelError, nil
default: default:
return slog.LevelInfo return 0, fmt.Errorf("invalid log level %q: want debug, info, warn, or error", s)
} }
} }
// SetLevel sets the default logger's minimum level.
func SetLevel(l slog.Level) { level.Set(l) }
// SetDefault swaps the package-level logger. Used by tests to capture output; // SetDefault swaps the package-level logger. Used by tests to capture output;
// production code never calls this. // production code never calls this.
func SetDefault(l *slog.Logger) { defaultLogger = l } func SetDefault(l *slog.Logger) { defaultLogger = l }
+11 -6
View File
@@ -32,8 +32,7 @@ func decodeOne(t *testing.T, buf *bytes.Buffer) map[string]any {
} }
func TestParseLevel(t *testing.T) { func TestParseLevel(t *testing.T) {
tests := map[string]slog.Level{ valid := map[string]slog.Level{
"": slog.LevelInfo,
"info": slog.LevelInfo, "info": slog.LevelInfo,
"INFO": slog.LevelInfo, "INFO": slog.LevelInfo,
"debug": slog.LevelDebug, "debug": slog.LevelDebug,
@@ -41,11 +40,17 @@ func TestParseLevel(t *testing.T) {
"warning": slog.LevelWarn, "warning": slog.LevelWarn,
"error": slog.LevelError, "error": slog.LevelError,
" Error ": slog.LevelError, " Error ": slog.LevelError,
"bogus": slog.LevelInfo,
} }
for in, want := range tests { for in, want := range valid {
if got := parseLevel(in); got != want { got, err := ParseLevel(in)
t.Errorf("parseLevel(%q) = %v, want %v", in, got, want) if err != nil || got != want {
t.Errorf("ParseLevel(%q) = %v, %v; want %v", in, got, err, want)
}
}
// No fallback: empty and unknown values are configuration errors.
for _, in := range []string{"", " ", "bogus"} {
if _, err := ParseLevel(in); err == nil {
t.Errorf("ParseLevel(%q) = nil error, want an error", in)
} }
} }
} }
@@ -94,17 +94,18 @@ func TestAddSticker_NonOwnerWritesToConfiguredPack(t *testing.T) {
} }
} }
func TestAddSticker_DefaultsToMiti99Pack(t *testing.T) { // There is no fallback pack: an unset STICKER_PACK_NAME must not write
// anywhere.
func TestAddSticker_RequiresPackName(t *testing.T) {
rb := installAddSticker(t, "", "miti99bot") rb := installAddSticker(t, "", "miti99bot")
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", "")) rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
call, ok := callTo(rb, "addStickerToSet") if _, ok := callTo(rb, "addStickerToSet"); ok {
if !ok { t.Fatal("addStickerToSet called without a configured pack name")
t.Fatalf("no addStickerToSet call; got %+v", rb.Sent())
} }
if got := call.Form["name"]; got != "miti99_by_miti99bot" { if _, ok := callTo(rb, "createNewStickerSet"); ok {
t.Errorf("name = %q, want the default pack", got) t.Fatal("createNewStickerSet called without a configured pack name")
} }
} }
+8 -7
View File
@@ -17,18 +17,15 @@ import (
) )
const ( const (
// stickerPackNameEnv overrides which set /addsticker writes to. The name // stickerPackNameEnv names the set /addsticker writes to; compose.yml
// must end in "_by_<bot_username>", the only thing that makes a set // supplies its default. The name must end in "_by_<bot_username>", the only thing that makes a set
// bot-manageable; packTitle checks it before any upload. A set that does // bot-manageable; packTitle checks it before any upload. A set that does
// not exist yet is created by the first successful /addsticker. // not exist yet is created by the first successful /addsticker.
stickerPackNameEnv = "STICKER_PACK_NAME" stickerPackNameEnv = "STICKER_PACK_NAME"
// defaultStickerPackName is the shared pack used when the env is unset.
defaultStickerPackName = "miti99_by_miti99bot"
// stickerPackOwnerEnv reuses the bot-wide owner setting rather than // stickerPackOwnerEnv reuses the bot-wide owner setting rather than
// introducing a second variable: AddStickerToSet needs the *set owner's* // introducing a second variable: AddStickerToSet needs the *set owner's*
// user ID, and the default pack above belongs to the bot owner. A pack // user ID, and the standard pack belongs to the bot owner. A pack
// owned by any other account needs this env pointed at that account. // owned by any other account needs this env pointed at that account.
stickerPackOwnerEnv = "OWNER_ID" stickerPackOwnerEnv = "OWNER_ID"
@@ -78,6 +75,10 @@ type stickerPack struct {
OwnerID int64 // the account the set belongs to; AddStickerToSet demands it OwnerID int64 // the account the set belongs to; AddStickerToSet demands it
} }
// errNoPackName means STICKER_PACK_NAME is unset, so no sticker can be added.
// Internal, not user-facing: nothing the caller does fixes a misconfiguration.
var errNoPackName = errors.New("util: sticker pack name unset")
// errNoPackOwner means the owner ID is unset, so no sticker can be added. // errNoPackOwner means the owner ID is unset, so no sticker can be added.
// Internal, not user-facing: nothing the caller does fixes a misconfiguration. // Internal, not user-facing: nothing the caller does fixes a misconfiguration.
var errNoPackOwner = errors.New("util: sticker pack owner ID unset") var errNoPackOwner = errors.New("util: sticker pack owner ID unset")
@@ -90,7 +91,7 @@ var errNoPackOwner = errors.New("util: sticker pack owner ID unset")
func loadStickerPack() (stickerPack, error) { func loadStickerPack() (stickerPack, error) {
name := strings.TrimSpace(os.Getenv(stickerPackNameEnv)) name := strings.TrimSpace(os.Getenv(stickerPackNameEnv))
if name == "" { if name == "" {
name = defaultStickerPackName return stickerPack{}, errNoPackName
} }
ownerID, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(stickerPackOwnerEnv)), 10, 64) ownerID, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(stickerPackOwnerEnv)), 10, 64)
if err != nil || ownerID == 0 { if err != nil || ownerID == 0 {
+14 -4
View File
@@ -106,26 +106,36 @@ func TestPackTitle_AtNameLengthCap(t *testing.T) {
} }
func TestLoadStickerPack(t *testing.T) { func TestLoadStickerPack(t *testing.T) {
t.Run("defaults the name and requires an owner", func(t *testing.T) { t.Run("reads the name and owner", func(t *testing.T) {
t.Setenv("OWNER_ID", "42") t.Setenv("OWNER_ID", "42")
t.Setenv("STICKER_PACK_NAME", "") t.Setenv("STICKER_PACK_NAME", "miti99_by_miti99bot")
pack, err := loadStickerPack() pack, err := loadStickerPack()
if err != nil { if err != nil {
t.Fatalf("loadStickerPack: %v", err) t.Fatalf("loadStickerPack: %v", err)
} }
if pack.Name != defaultStickerPackName { if pack.Name != "miti99_by_miti99bot" {
t.Errorf("name = %q, want %q", pack.Name, defaultStickerPackName) t.Errorf("name = %q, want miti99_by_miti99bot", pack.Name)
} }
if pack.OwnerID != 42 { if pack.OwnerID != 42 {
t.Errorf("ownerID = %d, want 42", pack.OwnerID) t.Errorf("ownerID = %d, want 42", pack.OwnerID)
} }
}) })
// No fallback name: an unset STICKER_PACK_NAME is a misconfiguration.
t.Run("requires a name", func(t *testing.T) {
t.Setenv("OWNER_ID", "42")
t.Setenv("STICKER_PACK_NAME", "")
if _, err := loadStickerPack(); !errors.Is(err, errNoPackName) {
t.Errorf("loadStickerPack() err = %v, want errNoPackName", err)
}
})
// A zero owner is the unset case, not a valid user: AddStickerToSet needs a // A zero owner is the unset case, not a valid user: AddStickerToSet needs a
// real account, so it must fail here rather than at the API. // real account, so it must fail here rather than at the API.
for _, owner := range []string{"", "0", "not-a-number"} { for _, owner := range []string{"", "0", "not-a-number"} {
t.Run("rejects owner "+owner, func(t *testing.T) { t.Run("rejects owner "+owner, func(t *testing.T) {
t.Setenv("OWNER_ID", owner) t.Setenv("OWNER_ID", owner)
t.Setenv("STICKER_PACK_NAME", "miti99_by_miti99bot")
if _, err := loadStickerPack(); !errors.Is(err, errNoPackOwner) { if _, err := loadStickerPack(); !errors.Is(err, errNoPackOwner) {
t.Errorf("loadStickerPack() err = %v, want errNoPackOwner", err) t.Errorf("loadStickerPack() err = %v, want errNoPackOwner", err)
} }
+2
View File
@@ -1,3 +1,5 @@
# Every RENDERER_* setting is required; the renderer has no fallback values.
# The root compose.yml supplies these same values as its defaults.
RENDERER_PORT=3000 RENDERER_PORT=3000
RENDERER_HOST=0.0.0.0 RENDERER_HOST=0.0.0.0
NODE_ENV=production NODE_ENV=production
+4 -2
View File
@@ -108,9 +108,11 @@ npm install
npm run browser:ensure npm run browser:ensure
``` ```
Start the local API: Start the local API. Every `RENDERER_*` setting is required, so copy the
template first; `npm run dev` loads `.env`:
```sh ```sh
cp .env.example .env
npm run dev npm run dev
``` ```
@@ -178,7 +180,7 @@ dependencies, and FFmpeg/compositor support.
```sh ```sh
docker build -t miti99bot-renderer . docker build -t miti99bot-renderer .
docker run --rm -p 3000:3000 miti99bot-renderer docker run --rm -p 3000:3000 --env-file .env.example 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
+6 -4
View File
@@ -28,7 +28,7 @@ Avoid for v1:
## Runtime ## Runtime
Env vars, all optional (defaults shown): Env vars, all required (standard values shown):
```sh ```sh
RENDERER_PORT=3000 RENDERER_PORT=3000
@@ -39,9 +39,11 @@ RENDERER_MAX_OPTIONS=32
RENDERER_MAX_OPTION_CHARS=40 RENDERER_MAX_OPTION_CHARS=40
``` ```
An unset or empty variable uses the default above. The root `compose.yml` The renderer has no fallback values: it refuses to start and lists every
fixes `RENDERER_HOST` and `RENDERER_PORT` and passes the tuning values through missing or invalid variable. The root `compose.yml` owns the defaults — it
without defaults, so leaving them empty in Coolify uses these defaults. fixes `RENDERER_HOST` and `RENDERER_PORT` and gives each tuning value a
`${VAR:-default}`, so leaving them empty in Coolify uses these values. For a
local run, copy `.env.example` to `.env`; `npm run dev` and `npm start` load it.
Start with 1-2 vCPU and 1-2 GB RAM. Increase only after render benchmarks show Start with 1-2 vCPU and 1-2 GB RAM. Increase only after render benchmarks show
the service is CPU-bound or concurrency-limited. the service is CPU-bound or concurrency-limited.
+2 -2
View File
@@ -7,8 +7,8 @@
"node": ">=24 <25" "node": ">=24 <25"
}, },
"scripts": { "scripts": {
"dev": "node --watch src/server.js", "dev": "node --env-file-if-exists=.env --watch src/server.js",
"start": "node src/server.js", "start": "node --env-file-if-exists=.env src/server.js",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
+40 -15
View File
@@ -11,33 +11,58 @@ export const minRenderTimeoutMs = 7000;
*/ */
/** /**
* @param {string | undefined} value * Reads a required positive integer setting, recording a problem instead of
* @param {number} fallback * falling back: compose.yml owns every default, so a missing value is a
* deployment mistake to report, not to paper over.
*
* @param {NodeJS.ProcessEnv} env
* @param {string} name
* @param {string[]} problems
* @returns {number} * @returns {number}
*/ */
const parsePositiveInt = (value, fallback) => { const requirePositiveInt = (env, name, problems) => {
if (!value) { const raw = env[name]?.trim();
return fallback; if (!raw) {
problems.push(`${name} is required`);
return 0;
} }
const parsed = Number(raw);
const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; problems.push(`${name} must be a positive integer, got "${raw}"`);
return 0;
}
return parsed;
}; };
/** /**
* Loads the renderer settings. Every RENDERER_* variable is required; the
* deployment (compose.yml, or .env for local runs) supplies the values.
*
* @param {NodeJS.ProcessEnv} [env] * @param {NodeJS.ProcessEnv} [env]
* @returns {AppConfig} * @returns {AppConfig}
*/ */
export const loadConfig = (env = process.env) => { export const loadConfig = (env = process.env) => {
return { /** @type {string[]} */
host: env.RENDERER_HOST || '0.0.0.0', const problems = [];
port: parsePositiveInt(env.RENDERER_PORT, 3000), const host = env.RENDERER_HOST?.trim() ?? '';
maxConcurrentRenders: parsePositiveInt(env.RENDERER_MAX_CONCURRENT_RENDERS, 1), if (!host) {
problems.push('RENDERER_HOST is required');
}
const config = {
host,
port: requirePositiveInt(env, 'RENDERER_PORT', problems),
maxConcurrentRenders: requirePositiveInt(env, 'RENDERER_MAX_CONCURRENT_RENDERS', problems),
// Remotion's browser timeout cannot go below 7000ms, so lower values are
// raised to that floor rather than rejected.
renderTimeoutMs: Math.max( renderTimeoutMs: Math.max(
minRenderTimeoutMs, minRenderTimeoutMs,
parsePositiveInt(env.RENDERER_RENDER_TIMEOUT_MS, 15000), requirePositiveInt(env, 'RENDERER_RENDER_TIMEOUT_MS', problems),
), ),
maxOptions: parsePositiveInt(env.RENDERER_MAX_OPTIONS, 32), maxOptions: requirePositiveInt(env, 'RENDERER_MAX_OPTIONS', problems),
maxOptionChars: parsePositiveInt(env.RENDERER_MAX_OPTION_CHARS, 40), maxOptionChars: requirePositiveInt(env, 'RENDERER_MAX_OPTION_CHARS', problems),
}; };
if (problems.length > 0) {
throw new Error(`invalid renderer configuration: ${problems.join('; ')}`);
}
return config;
}; };
+39 -1
View File
@@ -1,10 +1,48 @@
import {describe, expect, test} from 'vitest'; import {describe, expect, test} from 'vitest';
import {loadConfig, minRenderTimeoutMs} from '../src/config.js'; import {loadConfig, minRenderTimeoutMs} from '../src/config.js';
const validEnv = {
RENDERER_HOST: '0.0.0.0',
RENDERER_PORT: '3000',
RENDERER_MAX_CONCURRENT_RENDERS: '1',
RENDERER_RENDER_TIMEOUT_MS: '15000',
RENDERER_MAX_OPTIONS: '32',
RENDERER_MAX_OPTION_CHARS: '40',
};
describe('loadConfig', () => { describe('loadConfig', () => {
test('reads every RENDERER_* setting', () => {
expect(loadConfig(validEnv)).toEqual({
host: '0.0.0.0',
port: 3000,
maxConcurrentRenders: 1,
renderTimeoutMs: 15000,
maxOptions: 32,
maxOptionChars: 40,
});
});
test('keeps render timeout compatible with Remotion browser timeout limits', () => { test('keeps render timeout compatible with Remotion browser timeout limits', () => {
const config = loadConfig({RENDERER_RENDER_TIMEOUT_MS: '500'}); const config = loadConfig({...validEnv, RENDERER_RENDER_TIMEOUT_MS: '500'});
expect(config.renderTimeoutMs).toBe(minRenderTimeoutMs); expect(config.renderTimeoutMs).toBe(minRenderTimeoutMs);
}); });
test('has no fallback: missing settings are all reported', () => {
expect(() => loadConfig({})).toThrow(
/RENDERER_HOST is required.*RENDERER_PORT is required.*RENDERER_MAX_OPTION_CHARS is required/,
);
});
test('treats an empty value as missing', () => {
expect(() => loadConfig({...validEnv, RENDERER_MAX_OPTIONS: ''})).toThrow(
/RENDERER_MAX_OPTIONS is required/,
);
});
test.each(['0', '-1', '1.5', 'abc'])('rejects non-positive-integer %s', (value) => {
expect(() => loadConfig({...validEnv, RENDERER_MAX_OPTIONS: value})).toThrow(
/RENDERER_MAX_OPTIONS must be a positive integer/,
);
});
}); });