revert: restore code fallbacks for config defaults

This reverts commit 153fc21. With no fallback in code, the deploy
crash-looped: Coolify keeps an entry for every variable compose.yml has
referenced, and empty entries appear to reach the containers as empty
values instead of the compose defaults.
This commit is contained in:
tiennm99 committed 2026-10-03 12:42:16 +07:00
1 parent 153fc211cf
commit e418bf3390
17 files changed
+100 -214

No files matched your search

+3 -10
View File
@@ -25,8 +25,7 @@ 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. Required by /addsticker: # the first /addsticker if it does not exist yet. Unset = the default below.
# 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.
@@ -45,16 +44,10 @@ 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,8 +262,6 @@ 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
``` ```
@@ -271,15 +269,10 @@ 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.
+7 -9
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. compose.yml supplies every // loadConfig reads config from the environment. PORT defaults to 8080 and an
// default, so the code keeps none: a missing or invalid PORT or LOG_LEVEL is // invalid PORT is fatal; malformed OWNER_ID / ADMIN_IDS entries are logged and
// fatal. Malformed OWNER_ID / ADMIN_IDS entries are logged and ignored. // 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,17 +378,15 @@ func loadConfig() config {
envMap[kv[:eq]] = kv[eq+1:] envMap[kv[:eq]] = kv[eq+1:]
} }
} }
level, err := log.ParseLevel(envMap["LOG_LEVEL"]) port := envMap["PORT"]
if err != nil { if port == "" {
log.Fatal("invalid LOG_LEVEL", "err", err) port = "8080"
} }
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("missing or invalid PORT", "value", port) log.Fatal("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 means none / all) --- # --- Access control (optional; empty uses the default) ---
MODULES: ${MODULES:-} # CSV of modules; empty = all modules MODULES: ${MODULES} # CSV of modules; default: all modules
OWNER_ID: ${OWNER_ID:-} # Telegram user id for owner-only commands; empty = none OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands; default: none
ADMIN_IDS: ${ADMIN_IDS:-} # CSV of admin Telegram user ids; empty = none ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids; default: none
# --- Module settings (optional) --- # --- Module settings (optional; empty uses the default) ---
LOL_PANDASCORE_TOKEN: ${LOL_PANDASCORE_TOKEN:-} # PandaScore token — SECRET; empty = /lol* fetches fail LOL_PANDASCORE_TOKEN: ${LOL_PANDASCORE_TOKEN} # PandaScore token — SECRET; default: none (/lol* fetches fail)
GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY:-} # VNAppMob key — SECRET; empty = fetched and cached in Mongo GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY} # VNAppMob key — SECRET; default: fetched and cached in Mongo
STICKER_PACK_NAME: ${STICKER_PACK_NAME:-miti99_by_miti99bot} # /addsticker set; must end in _by_<bot username> STICKER_PACK_NAME: ${STICKER_PACK_NAME} # /addsticker set; default: miti99_by_miti99bot
# --- Runtime (defaults below; the code has no fallback) --- # --- Runtime (optional; empty uses the default) ---
LOG_LEVEL: ${LOG_LEVEL:-info} # debug|info|warn|error LOG_LEVEL: ${LOG_LEVEL} # debug|info|warn|error; default: info
# --- 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 (defaults below; the renderer has no fallback) --- # --- Tuning (optional; empty uses the default) ---
RENDERER_MAX_CONCURRENT_RENDERS: ${RENDERER_MAX_CONCURRENT_RENDERS:-1} RENDERER_MAX_CONCURRENT_RENDERS: ${RENDERER_MAX_CONCURRENT_RENDERS} # default: 1
RENDERER_RENDER_TIMEOUT_MS: ${RENDERER_RENDER_TIMEOUT_MS:-15000} # floor 7000 RENDERER_RENDER_TIMEOUT_MS: ${RENDERER_RENDER_TIMEOUT_MS} # default: 15000 (floor 7000)
RENDERER_MAX_OPTIONS: ${RENDERER_MAX_OPTIONS:-32} # wheel options RENDERER_MAX_OPTIONS: ${RENDERER_MAX_OPTIONS} # default: 32 wheel options
RENDERER_MAX_OPTION_CHARS: ${RENDERER_MAX_OPTION_CHARS:-40} # chars per option/label RENDERER_MAX_OPTION_CHARS: ${RENDERER_MAX_OPTION_CHARS} # default: 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
+3 -8
View File
@@ -34,20 +34,15 @@ 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; `compose.yml` 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` | 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`, `warn`, or `error`; `compose.yml` default `info`; logs are JSON on stdout | | `LOG_LEVEL` | optional | `debug`, `info` (default), `warn`, or `error`; 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; fixed to `8080` by `compose.yml`, which the health check uses | | `PORT` | leave unset | health server port; default `8080` |
| `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 | `compose.yml` default | Meaning | | Env | Default | Meaning |
|---|---|---| |---|---|---|
| `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to; required — unset, `/addsticker` refuses | | `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to |
| `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
standard pack belongs to the bot owner. Point `OWNER_ID` at the owning account if default 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:
+21 -28
View File
@@ -19,42 +19,35 @@ package log
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"os" "os"
"strings" "strings"
) )
// level is the default logger's minimum level. It starts at Info so logging // defaultLogger is constructed at init from LOG_LEVEL. Tests can swap it via
// works before configuration is read (and in tests); SetLevel applies the // SetDefault — but the public Info/Warn/Error/Fatal helpers always read the
// configured LOG_LEVEL at startup. // current default so test substitutions take effect immediately.
var level = new(slog.LevelVar) var defaultLogger *slog.Logger
// defaultLogger writes JSON to stdout. Tests can swap it via SetDefault — but func init() {
// the public Info/Warn/Error/Fatal helpers always read the current default so defaultLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
// test substitutions take effect immediately. Level: parseLevel(os.Getenv("LOG_LEVEL")),
var defaultLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})) }))
// ParseLevel maps a LOG_LEVEL value to a slog.Level. There is no fallback: an
// 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)) {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return 0, fmt.Errorf("invalid log level %q: want debug, info, warn, or error", s)
}
} }
// SetLevel sets the default logger's minimum level. // parseLevel maps LOG_LEVEL env to a slog.Level. Unknown / empty → Info.
func SetLevel(l slog.Level) { level.Set(l) } func parseLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// 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.
+6 -11
View File
@@ -32,7 +32,8 @@ func decodeOne(t *testing.T, buf *bytes.Buffer) map[string]any {
} }
func TestParseLevel(t *testing.T) { func TestParseLevel(t *testing.T) {
valid := map[string]slog.Level{ tests := map[string]slog.Level{
"": slog.LevelInfo,
"info": slog.LevelInfo, "info": slog.LevelInfo,
"INFO": slog.LevelInfo, "INFO": slog.LevelInfo,
"debug": slog.LevelDebug, "debug": slog.LevelDebug,
@@ -40,17 +41,11 @@ 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 valid { for in, want := range tests {
got, err := ParseLevel(in) if got := parseLevel(in); got != want {
if err != nil || got != want { t.Errorf("parseLevel(%q) = %v, want %v", in, 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,18 +94,17 @@ func TestAddSticker_NonOwnerWritesToConfiguredPack(t *testing.T) {
} }
} }
// There is no fallback pack: an unset STICKER_PACK_NAME must not write func TestAddSticker_DefaultsToMiti99Pack(t *testing.T) {
// 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", ""))
if _, ok := callTo(rb, "addStickerToSet"); ok { call, ok := callTo(rb, "addStickerToSet")
t.Fatal("addStickerToSet called without a configured pack name") if !ok {
t.Fatalf("no addStickerToSet call; got %+v", rb.Sent())
} }
if _, ok := callTo(rb, "createNewStickerSet"); ok { if got := call.Form["name"]; got != "miti99_by_miti99bot" {
t.Fatal("createNewStickerSet called without a configured pack name") t.Errorf("name = %q, want the default pack", got)
} }
} }
+7 -8
View File
@@ -17,15 +17,18 @@ import (
) )
const ( const (
// stickerPackNameEnv names the set /addsticker writes to; compose.yml // stickerPackNameEnv overrides which set /addsticker writes to. The name
// supplies its default. The name must end in "_by_<bot_username>", the only thing that makes a set // 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 standard pack belongs to the bot owner. A pack // user ID, and the default pack above 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"
@@ -75,10 +78,6 @@ 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")
@@ -91,7 +90,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 == "" {
return stickerPack{}, errNoPackName name = defaultStickerPackName
} }
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 {
+4 -14
View File
@@ -106,36 +106,26 @@ func TestPackTitle_AtNameLengthCap(t *testing.T) {
} }
func TestLoadStickerPack(t *testing.T) { func TestLoadStickerPack(t *testing.T) {
t.Run("reads the name and owner", func(t *testing.T) { t.Run("defaults the name and requires an owner", func(t *testing.T) {
t.Setenv("OWNER_ID", "42") t.Setenv("OWNER_ID", "42")
t.Setenv("STICKER_PACK_NAME", "miti99_by_miti99bot") t.Setenv("STICKER_PACK_NAME", "")
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 != "miti99_by_miti99bot" { if pack.Name != defaultStickerPackName {
t.Errorf("name = %q, want miti99_by_miti99bot", pack.Name) t.Errorf("name = %q, want %q", pack.Name, defaultStickerPackName)
} }
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,5 +1,3 @@
# 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
+2 -4
View File
@@ -108,11 +108,9 @@ npm install
npm run browser:ensure npm run browser:ensure
``` ```
Start the local API. Every `RENDERER_*` setting is required, so copy the Start the local API:
template first; `npm run dev` loads `.env`:
```sh ```sh
cp .env.example .env
npm run dev npm run dev
``` ```
@@ -180,7 +178,7 @@ dependencies, and FFmpeg/compositor support.
```sh ```sh
docker build -t miti99bot-renderer . docker build -t miti99bot-renderer .
docker run --rm -p 3000:3000 --env-file .env.example miti99bot-renderer 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
+4 -6
View File
@@ -28,7 +28,7 @@ Avoid for v1:
## Runtime ## Runtime
Env vars, all required (standard values shown): Env vars, all optional (defaults shown):
```sh ```sh
RENDERER_PORT=3000 RENDERER_PORT=3000
@@ -39,11 +39,9 @@ RENDERER_MAX_OPTIONS=32
RENDERER_MAX_OPTION_CHARS=40 RENDERER_MAX_OPTION_CHARS=40
``` ```
The renderer has no fallback values: it refuses to start and lists every An unset or empty variable uses the default above. The root `compose.yml`
missing or invalid variable. The root `compose.yml` owns the defaults — it fixes `RENDERER_HOST` and `RENDERER_PORT` and passes the tuning values through
fixes `RENDERER_HOST` and `RENDERER_PORT` and gives each tuning value a without defaults, so leaving them empty in Coolify uses these defaults.
`${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 --env-file-if-exists=.env --watch src/server.js", "dev": "node --watch src/server.js",
"start": "node --env-file-if-exists=.env src/server.js", "start": "node src/server.js",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
+15 -40
View File
@@ -11,58 +11,33 @@ export const minRenderTimeoutMs = 7000;
*/ */
/** /**
* Reads a required positive integer setting, recording a problem instead of * @param {string | undefined} value
* falling back: compose.yml owns every default, so a missing value is a * @param {number} fallback
* deployment mistake to report, not to paper over.
*
* @param {NodeJS.ProcessEnv} env
* @param {string} name
* @param {string[]} problems
* @returns {number} * @returns {number}
*/ */
const requirePositiveInt = (env, name, problems) => { const parsePositiveInt = (value, fallback) => {
const raw = env[name]?.trim(); if (!value) {
if (!raw) { return fallback;
problems.push(`${name} is required`);
return 0;
} }
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) { const parsed = Number.parseInt(value, 10);
problems.push(`${name} must be a positive integer, got "${raw}"`); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
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) => {
/** @type {string[]} */ return {
const problems = []; host: env.RENDERER_HOST || '0.0.0.0',
const host = env.RENDERER_HOST?.trim() ?? ''; port: parsePositiveInt(env.RENDERER_PORT, 3000),
if (!host) { maxConcurrentRenders: parsePositiveInt(env.RENDERER_MAX_CONCURRENT_RENDERS, 1),
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,
requirePositiveInt(env, 'RENDERER_RENDER_TIMEOUT_MS', problems), parsePositiveInt(env.RENDERER_RENDER_TIMEOUT_MS, 15000),
), ),
maxOptions: requirePositiveInt(env, 'RENDERER_MAX_OPTIONS', problems), maxOptions: parsePositiveInt(env.RENDERER_MAX_OPTIONS, 32),
maxOptionChars: requirePositiveInt(env, 'RENDERER_MAX_OPTION_CHARS', problems), maxOptionChars: parsePositiveInt(env.RENDERER_MAX_OPTION_CHARS, 40),
}; };
if (problems.length > 0) {
throw new Error(`invalid renderer configuration: ${problems.join('; ')}`);
}
return config;
}; };
+1 -39
View File
@@ -1,48 +1,10 @@
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({...validEnv, RENDERER_RENDER_TIMEOUT_MS: '500'}); const config = loadConfig({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/,
);
});
}); });