vojo/apps/ai-bot/config.go

263 lines
8.7 KiB
Go

package main
import (
"fmt"
"os"
"strconv"
"strings"
)
// Config is the fully-resolved runtime configuration, parsed once from the
// environment at startup. Secrets (AS_TOKEN, HS_TOKEN, XAI_API_KEY) live ONLY
// here — never in config.json or any client bundle.
type Config struct {
HomeserverURL string
BotMXID string
BotDisplayName string
// Appservice auth, from the Synapse registration.yaml. `as_token`
// authenticates the bot TO the homeserver (used as the access token, with
// ?user_id=BOT_MXID identity assertion); `hs_token` authenticates the
// homeserver's transaction pushes TO us. Neither expires — no rotation.
ASToken string
HSToken string
// Listen address for the transaction-push HTTP server (the `url` in the
// registration points here, e.g. http://ai-bot:8009).
ASAddr string
// When set, as_token/hs_token are read from this generated registration.yaml
// (the mautrix idiom — one file shared with Synapse), overriding the env
// AS_TOKEN/HS_TOKEN. Empty → use the env tokens.
RegistrationPath string
XAIAPIKey string
XAIBaseURL string
XAIModel string
XAITemp float64
MaxOutTok int
MaxCtxEvent int
// Allowlist of homeservers whose users may pull the bot into a room. Gates
// the *inviter* (F11). Comma-separated env, stored as a set.
AllowedServers map[string]bool
DailyUSDCeiling float64
PerUserDailyCap int
// mxids exempt from PER_USER_DAILY_CAP (e.g. the owner/admins testing). Still
// subject to the global DAILY_USD_CEILING, so the wallet stays protected.
UnlimitedUsers map[string]bool
// USD-per-1M-token prices applied to the API-returned token usage so the
// hard ceiling tracks real usage even if the model/price changes.
PriceInputPerM float64
PriceCachedPerM float64
PriceOutputPerM float64
SystemPromptPath string
SystemPrompt string
StateDir string
}
func getenv(key, def string) string {
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
return v
}
return def
}
// getSecret resolves a secret with optional file indirection: if `<key>_FILE`
// is set, the value is read from that file (trailing whitespace trimmed) — the
// standard Docker-secret / mounted-file convention, so the tokens can live in a
// separate read-only mount instead of inline in the config env (and never enter
// `docker inspect`/`/proc/<pid>/environ`). Falls back to the plain `<key>` env.
func getSecret(key string) (string, error) {
if path := strings.TrimSpace(os.Getenv(key + "_FILE")); path != "" {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("%s_FILE (%s): %w", key, path, err)
}
return strings.TrimSpace(string(b)), nil
}
return getenv(key, ""), nil
}
func getenvInt(key string, def int) (int, error) {
raw := getenv(key, "")
if raw == "" {
return def, nil
}
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return 0, fmt.Errorf("%s must be an integer, got %q", key, raw)
}
return n, nil
}
func getenvFloat(key string, def float64) (float64, error) {
raw := getenv(key, "")
if raw == "" {
return def, nil
}
f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return 0, fmt.Errorf("%s must be a number, got %q", key, raw)
}
return f, nil
}
func parseServerSet(raw string) map[string]bool {
set := make(map[string]bool)
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if s != "" {
set[s] = true
}
}
return set
}
// LoadConfig parses and validates the environment. It returns an error listing
// every missing/invalid required field at once so the operator fixes them in a
// single pass rather than discovering them one container-restart at a time.
func LoadConfig() (*Config, error) {
cfg := &Config{
HomeserverURL: strings.TrimRight(getenv("HOMESERVER_URL", ""), "/"),
BotMXID: getenv("BOT_MXID", ""),
BotDisplayName: getenv("BOT_DISPLAY_NAME", "Vojo AI"),
ASAddr: getenv("AS_ADDR", ":8009"),
RegistrationPath: getenv("REGISTRATION_PATH", ""),
XAIBaseURL: strings.TrimRight(getenv("XAI_BASE_URL", "https://api.x.ai/v1"), "/"),
XAIModel: getenv("XAI_MODEL", "grok-4.20-0309-non-reasoning"),
SystemPromptPath: getenv("SYSTEM_PROMPT_PATH", "prompts/system_ru.txt"),
StateDir: strings.TrimRight(getenv("STATE_DIR", "/state"), "/"),
AllowedServers: parseServerSet(getenv("ALLOWED_SERVERS", "")),
UnlimitedUsers: parseServerSet(getenv("UNLIMITED_USERS", "")),
}
var problems []string
// Secrets support *_FILE indirection so they can be separate mounts / Docker
// secrets, decoupled from the non-secret config env.
for _, s := range []struct {
key string
dest *string
}{
{"AS_TOKEN", &cfg.ASToken},
{"HS_TOKEN", &cfg.HSToken},
{"XAI_API_KEY", &cfg.XAIAPIKey},
} {
v, err := getSecret(s.key)
if err != nil {
problems = append(problems, err.Error())
}
*s.dest = v
}
// A generated registration.yaml, when provided, is the source of truth for
// the appservice tokens (mautrix idiom — the same file Synapse reads),
// overriding any env AS_TOKEN/HS_TOKEN.
if cfg.RegistrationPath != "" {
reg, err := LoadRegistration(cfg.RegistrationPath)
if err != nil {
problems = append(problems, err.Error())
} else {
cfg.ASToken, cfg.HSToken = reg.ASToken, reg.HSToken
if lp := localpartOf(cfg.BotMXID); lp != "" && reg.SenderLocalpart != "" && lp != reg.SenderLocalpart {
problems = append(problems, fmt.Sprintf(
"registration sender_localpart %q != BOT_MXID localpart %q", reg.SenderLocalpart, lp))
}
}
}
req := func(name, val string) {
if val == "" {
problems = append(problems, name+" is required")
}
}
req("HOMESERVER_URL", cfg.HomeserverURL)
req("BOT_MXID", cfg.BotMXID)
req("AS_TOKEN", cfg.ASToken)
req("HS_TOKEN", cfg.HSToken)
req("XAI_API_KEY", cfg.XAIAPIKey)
if len(cfg.AllowedServers) == 0 {
problems = append(problems, "ALLOWED_SERVERS is required (comma-separated homeserver allowlist)")
}
var err error
if cfg.XAITemp, err = getenvFloat("XAI_TEMPERATURE", 0.6); err != nil {
problems = append(problems, err.Error())
}
if cfg.MaxOutTok, err = getenvInt("MAX_OUTPUT_TOKENS", 320); err != nil {
problems = append(problems, err.Error())
}
if cfg.MaxCtxEvent, err = getenvInt("MAX_CONTEXT_EVENTS", 20); err != nil {
problems = append(problems, err.Error())
}
if cfg.DailyUSDCeiling, err = getenvFloat("DAILY_USD_CEILING", 10); err != nil {
problems = append(problems, err.Error())
}
if cfg.PerUserDailyCap, err = getenvInt("PER_USER_DAILY_CAP", 30); err != nil {
problems = append(problems, err.Error())
}
if cfg.PriceInputPerM, err = getenvFloat("XAI_PRICE_INPUT_PER_M", 1.25); err != nil {
problems = append(problems, err.Error())
}
if cfg.PriceCachedPerM, err = getenvFloat("XAI_PRICE_CACHED_PER_M", 0.20); err != nil {
problems = append(problems, err.Error())
}
if cfg.PriceOutputPerM, err = getenvFloat("XAI_PRICE_OUTPUT_PER_M", 2.50); err != nil {
problems = append(problems, err.Error())
}
if len(problems) > 0 {
return nil, fmt.Errorf("invalid configuration:\n - %s", strings.Join(problems, "\n - "))
}
return cfg, nil
}
// Summary returns a human-readable, SECRET-REDACTED dump for the startup log.
func (c *Config) Summary() string {
servers := make([]string, 0, len(c.AllowedServers))
for s := range c.AllowedServers {
servers = append(servers, s)
}
unlimited := make([]string, 0, len(c.UnlimitedUsers))
for u := range c.UnlimitedUsers {
unlimited = append(unlimited, u)
}
redact := func(s string) string {
if s == "" {
return "(unset)"
}
return "set(" + strconv.Itoa(len(s)) + " chars)"
}
return strings.Join([]string{
"ai-bot config:",
" HOMESERVER_URL = " + c.HomeserverURL,
" BOT_MXID = " + c.BotMXID,
" BOT_DISPLAY_NAME = " + c.BotDisplayName,
" AS_ADDR = " + c.ASAddr,
" REGISTRATION_PATH = " + func() string {
if c.RegistrationPath == "" {
return "(unset — using env tokens)"
}
return c.RegistrationPath
}(),
" AS_TOKEN = " + redact(c.ASToken),
" HS_TOKEN = " + redact(c.HSToken),
" XAI_BASE_URL = " + c.XAIBaseURL,
" XAI_MODEL = " + c.XAIModel,
" XAI_API_KEY = " + redact(c.XAIAPIKey),
fmt.Sprintf(" XAI_TEMPERATURE = %g", c.XAITemp),
fmt.Sprintf(" MAX_OUTPUT_TOKENS = %d", c.MaxOutTok),
fmt.Sprintf(" MAX_CONTEXT_EVENTS = %d", c.MaxCtxEvent),
" ALLOWED_SERVERS = " + strings.Join(servers, ","),
fmt.Sprintf(" DAILY_USD_CEILING = %g", c.DailyUSDCeiling),
fmt.Sprintf(" PER_USER_DAILY_CAP = %d", c.PerUserDailyCap),
" UNLIMITED_USERS = " + strings.Join(unlimited, ","),
fmt.Sprintf(" PRICES /1M (in/cached/out) = %g / %g / %g",
c.PriceInputPerM, c.PriceCachedPerM, c.PriceOutputPerM),
" SYSTEM_PROMPT_PATH = " + c.SystemPromptPath,
" STATE_DIR = " + c.StateDir,
}, "\n")
}