chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

View File

@@ -0,0 +1,588 @@
package config
import (
"agent-desk/internal/pkg/enums"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
"github.com/subosito/gotenv"
)
type Config struct {
Language string `yaml:"language"`
Server ServerConfig `yaml:"server"`
DB DBConfig `yaml:"db"`
Logger LoggerConfig `yaml:"logger"`
Auth AuthConfig `yaml:"auth"`
Storage StorageConfig `yaml:"storage"`
VectorDB VectorDBConfig `yaml:"vectorDB"`
MCP MCPConfig `yaml:"mcp"`
OIDC OIDCConfig `yaml:"oidc"`
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
Identity IdentityConfig `yaml:"identity"`
Conversation ConversationConfig `yaml:"conversation"`
Webhook WebhookConfig `yaml:"webhook"`
Discord DiscordConfig `yaml:"discord"`
Email EmailConfig `yaml:"email"`
Slack SlackConfig `yaml:"slack"`
}
func (c Config) LanguageOrDefault() string {
switch strings.ToLower(strings.TrimSpace(c.Language)) {
case "zh", "zh-cn", "zh_cn", "zh-hans":
return "zh-CN"
case "en", "en-us", "en_us":
return "en-US"
case "ru", "ru-ru", "ru_ru":
return "ru-RU"
default:
return "en-US"
}
}
type ServerConfig struct {
Port int `yaml:"port"`
CompanyName string `yaml:"companyName"`
CompanyLogoURL string `yaml:"companyLogoUrl"`
CORS CORSConfig `yaml:"cors"`
// TrustedProxies are the CIDR blocks of the reverse proxies that sit in front
// of the application. Gin's own default is 0.0.0.0/0 and ::/0, which trusts
// every peer and makes ClientIP() return the leftmost X-Forwarded-For value -
// a header any caller can set.
TrustedProxies []string `yaml:"trustedProxies"`
// TrustedPlatform names an edge that overwrites rather than appends the real
// client address, for example "cloudflare". When set it takes precedence over
// X-Forwarded-For entirely.
TrustedPlatform string `yaml:"trustedPlatform"`
RateLimit RateLimitConfig `yaml:"rateLimit"`
}
// defaultTrustedProxies covers loopback, RFC1918, IPv6 unique-local and
// link-local ranges. That is the shape of almost every real deployment - a
// sidecar tunnel, a compose network, a local nginx - and a client on the public
// internet cannot present one of these addresses as its direct peer, so
// X-Forwarded-For stays honest. When the app is exposed directly the peer is a
// public address, is not trusted, and Gin falls back to it.
var defaultTrustedProxies = []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
}
func (s ServerConfig) TrustedProxiesOrDefault() []string {
proxies := make([]string, 0, len(s.TrustedProxies))
for _, proxy := range s.TrustedProxies {
if proxy = strings.TrimSpace(proxy); proxy != "" {
proxies = append(proxies, proxy)
}
}
if len(proxies) == 0 {
return defaultTrustedProxies
}
return proxies
}
// TrustedPlatformHeader resolves the configured platform name to the header Gin
// should read the client address from. Recognised names map to Gin's own
// constants; any other non-empty value is passed through as a literal header
// name, which is what Gin's TrustedPlatform field expects.
func (s ServerConfig) TrustedPlatformHeader() string {
platform := strings.TrimSpace(s.TrustedPlatform)
if platform == "" {
return ""
}
switch strings.ToLower(platform) {
case "cloudflare", "cf":
return "CF-Connecting-IP"
case "fly.io", "flyio", "fly-io":
return "Fly-Client-IP"
case "google-app-engine", "appengine", "gae":
return "X-Appengine-Remote-Addr"
default:
return platform
}
}
func (s ServerConfig) Address() string {
if s.Port <= 0 {
return ":8080"
}
return fmt.Sprintf(":%d", s.Port)
}
type CORSConfig struct {
// AllowedOrigins 是允许浏览器跨域访问的 Origin 白名单,必须包含协议和域名。
// 留空表示不允许跨域请求;同源请求通常不会携带 Origin,不受影响。
AllowedOrigins []string `yaml:"allowedOrigins"`
}
// RateLimitConfig bounds how often one client address may call the public,
// unauthenticated endpoints. It deliberately does not cover channel webhooks,
// websockets or authenticated dashboard routes: a platform that receives a 429
// from a webhook endpoint stops retrying and eventually disables the webhook.
type RateLimitConfig struct {
// Enabled defaults to true. Set it to false to switch the limits off without
// taking them out of the route table.
Enabled *bool `yaml:"enabled"`
// WindowSeconds is the length of the counting window. Zero or negative means
// one minute.
WindowSeconds int `yaml:"windowSeconds"`
}
func (r RateLimitConfig) IsEnabled() bool {
if r.Enabled == nil {
return true
}
return *r.Enabled
}
func (r RateLimitConfig) WindowSecondsOrDefault() int {
if r.WindowSeconds <= 0 {
return 60
}
return r.WindowSeconds
}
type DBConfig struct {
Type string `yaml:"type"`
DSN string `yaml:"dsn"`
MaxIdleConns int `yaml:"maxIdleConns"`
MaxOpenConns int `yaml:"maxOpenConns"`
ConnMaxIdleTimeSeconds int `yaml:"connMaxIdleTimeSeconds"`
ConnMaxLifetimeSeconds int `yaml:"connMaxLifetimeSeconds"`
}
type LoggerConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
AddSource bool `yaml:"addSource"`
}
type AuthConfig struct {
PasswordLoginEnabled *bool `yaml:"passwordLoginEnabled"`
TokenTTLHours int `yaml:"tokenTTLHours"`
MaxFailedAttempts int `yaml:"maxFailedAttempts"`
// MaxFailedAttemptsPerIP bounds failures from one client address across every
// username, which is what credential stuffing looks like. Zero or unset
// derives four times MaxFailedAttempts; it is disabled when MaxFailedAttempts
// is disabled.
MaxFailedAttemptsPerIP int `yaml:"maxFailedAttemptsPerIP"`
CredentialLockMinute int `yaml:"credentialLockMinute"`
}
// MaxFailedAttemptsPerIPOrDefault derives the per-address threshold from the
// per-account one so that a deployment which only tunes MaxFailedAttempts still
// gets a coherent pair of limits.
func (a AuthConfig) MaxFailedAttemptsPerIPOrDefault() int {
if a.MaxFailedAttemptsPerIP > 0 {
return a.MaxFailedAttemptsPerIP
}
if a.MaxFailedAttempts <= 0 {
return 0
}
return a.MaxFailedAttempts * 4
}
func (a AuthConfig) IsPasswordLoginEnabled() bool {
if a.PasswordLoginEnabled == nil {
return true
}
return *a.PasswordLoginEnabled
}
type CustomerSessionConfig struct {
Secret string `yaml:"secret"`
TTLMinutes int `yaml:"ttlMinutes"`
RefreshThresholdMinutes int `yaml:"refreshThresholdMinutes"`
}
// IdentityConfig holds the deployment-wide shared secret a host platform uses to
// sign the external visitor ID it passes to the widget.
type IdentityConfig struct {
// Secret is the HMAC-SHA256 key. Leave it empty to disable the feature: a
// visitor whose ID carries no valid signature is then always treated as an
// anonymous guest, which is the safe default.
Secret string `yaml:"secret"`
// MaxAgeMinutes bounds how old a signed identity may be. It limits how long a
// captured signature stays replayable. Values <= 0 fall back to 24 hours.
MaxAgeMinutes int `yaml:"maxAgeMinutes"`
}
func (c IdentityConfig) MaxAge() time.Duration {
if c.MaxAgeMinutes <= 0 {
return 24 * time.Hour
}
return time.Duration(c.MaxAgeMinutes) * time.Minute
}
// ConversationConfig holds visitor-facing conversation limits.
type ConversationConfig struct {
// CustomerMaxOpen caps how many not-yet-closed conversations one visitor may
// have at a time. It exists to protect agents, not the visitor: every open
// conversation is real work, and in human-only mode opening one dispatches
// an agent immediately. Without a cap a single caller could flood the queue,
// and the visitor cannot undo that by closing a conversation.
//
// 0 or unset falls back to the default of 3. Use -1 to remove the cap
// entirely, which is an explicit opt-out rather than something reached by
// omission.
CustomerMaxOpen int `yaml:"customerMaxOpen"`
// EmailChatLiveMinutes is how long a chat still counts as being watched. An
// email that arrives while a chat is live is answered in the chat instead of
// starting an email thread, because the visitor is present there and absent
// from their inbox. Past this window the chat is treated as abandoned and the
// email becomes the conversation.
EmailChatLiveMinutes int `yaml:"emailChatLiveMinutes"`
}
func (c ConversationConfig) EmailChatLiveWindow() time.Duration {
if c.EmailChatLiveMinutes <= 0 {
return 30 * time.Minute
}
return time.Duration(c.EmailChatLiveMinutes) * time.Minute
}
func (c ConversationConfig) MaxOpen() int {
if c.CustomerMaxOpen < 0 {
return 0
}
if c.CustomerMaxOpen == 0 {
return 3
}
return c.CustomerMaxOpen
}
func (c CustomerSessionConfig) TTL() int {
if c.TTLMinutes <= 0 {
return 120
}
return c.TTLMinutes
}
func (c CustomerSessionConfig) RefreshThreshold() int {
if c.RefreshThresholdMinutes <= 0 {
return 30
}
return c.RefreshThresholdMinutes
}
type StorageConfig struct {
Default enums.AssetProvider `yaml:"default"`
MaxUploadSizeMB int64 `yaml:"maxUploadSizeMB"`
Local LocalStorageConfig `yaml:"local"`
OSS OSSStorageConfig `yaml:"oss"`
}
func (s StorageConfig) MaxUploadSizeBytes() int64 {
if s.MaxUploadSizeMB <= 0 {
return 5 << 20
}
return s.MaxUploadSizeMB << 20
}
func (s StorageConfig) MaxRequestBodySizeBytes() int64 {
limit := s.MaxUploadSizeBytes()
return limit + (1 << 20)
}
type LocalStorageConfig struct {
Root string `yaml:"root"`
BaseURL string `yaml:"baseUrl"`
}
type OSSStorageConfig struct {
Endpoint string `yaml:"endpoint"`
Bucket string `yaml:"bucket"`
AccessKeyID string `yaml:"accessKeyId"`
AccessKeySecret string `yaml:"accessKeySecret"`
BaseURL string `yaml:"baseUrl"`
Private bool `yaml:"private"`
SignedURLExpire int `yaml:"signedUrlExpireSeconds"`
}
type VectorDBConfig struct {
Type string `yaml:"type"`
Qdrant QdrantVectorDBConfig `yaml:"qdrant"`
LanceDB LanceDBVectorDBConfig `yaml:"lancedb"`
}
type QdrantVectorDBConfig struct {
Host string `yaml:"host"`
GrpcPort int `yaml:"grpcPort"`
APIKey string `yaml:"apiKey"`
UseTLS bool `yaml:"useTls"`
}
type LanceDBVectorDBConfig struct {
Path string `yaml:"path"`
}
type MCPConfig struct {
Enabled bool `yaml:"enabled"`
Servers map[string]MCPServerConfig `yaml:"servers"`
}
type MCPServerConfig struct {
Enabled bool `yaml:"enabled"`
Endpoint string `yaml:"endpoint"`
TimeoutMS int `yaml:"timeoutMs"`
Headers map[string]string `yaml:"headers"`
}
type OIDCConfig struct {
Enabled bool `yaml:"enabled"`
Issuer string `yaml:"issuer"`
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
RedirectURL string `yaml:"redirectUrl"`
StateSecret string `yaml:"stateSecret"`
Scopes []string `yaml:"scopes"`
}
type WebhookConfig struct {
OrgSyncSecret string `yaml:"orgSyncSecret"`
DOSOrgSyncSecret string `yaml:"dosOrgSyncSecret"`
}
// DiscordConfig holds deployment-wide Discord bot credentials. A channel may
// carry its own bot token, which takes precedence; these are the fallback for a
// single shared bot.
type DiscordConfig struct {
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
BotToken string `yaml:"botToken"`
PublicKey string `yaml:"publicKey"`
}
// EmailConfig holds the email delivery provider settings.
type EmailConfig struct {
Provider string `yaml:"provider"`
FromAddress string `yaml:"fromAddress"`
FromName string `yaml:"fromName"`
APIKey string `yaml:"apiKey"`
SMTPHost string `yaml:"smtpHost"`
SMTPPort int `yaml:"smtpPort"`
SMTPUser string `yaml:"smtpUser"`
SMTPPassword string `yaml:"smtpPassword"`
SMTPUseTLS bool `yaml:"smtpUseTls"`
// SMTPAllowInsecure accepts a self-signed certificate and skips opportunistic
// STARTTLS so a deployment can be pointed at a local test SMTP server. It
// weakens transport security and must stay off for real mailboxes.
SMTPAllowInsecure bool `yaml:"smtpAllowInsecure"`
InboundSecret string `yaml:"inboundSecret"`
}
// SlackConfig holds deployment-wide Slack app credentials. A channel may carry
// its own bot token and signing secret, which take precedence; these are the
// fallback for a single shared Slack app.
type SlackConfig struct {
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
BotToken string `yaml:"botToken"`
SigningSecret string `yaml:"signingSecret"`
}
// SlackApp resolves the Slack app credentials for one channel. A channel value
// wins over the deployment-wide one, so a single deployment can serve several
// workspaces while still having a default app.
func (c Config) SlackApp(channelBotToken, channelSigningSecret string) SlackConfig {
return SlackConfig{
ClientID: strings.TrimSpace(c.Slack.ClientID),
ClientSecret: strings.TrimSpace(c.Slack.ClientSecret),
BotToken: firstNonBlank(channelBotToken, c.Slack.BotToken),
SigningSecret: firstNonBlank(channelSigningSecret, c.Slack.SigningSecret),
}
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func Load(path string) (*Config, error) {
loadDotEnv(path)
v := viper.New()
bindConfigDefaults(v)
if strings.TrimSpace(path) != "" {
v.SetConfigFile(path)
v.SetConfigType("yaml")
}
v.SetEnvPrefix("AGENT_DESK")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
bindEnvironmentAliases(v)
if strings.TrimSpace(path) != "" {
if err := v.ReadInConfig(); err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if !os.IsNotExist(err) && !errors.As(err, &configFileNotFoundError) {
return nil, err
}
}
}
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, err
}
normalizeLoadedConfig(cfg)
return cfg, nil
}
func loadDotEnv(configPath string) {
if envFile := os.Getenv("AGENT_DESK_ENV_FILE"); envFile != "" {
_ = gotenv.Load(envFile)
return
}
if envFile := os.Getenv("ENV_FILE"); envFile != "" {
_ = gotenv.Load(envFile)
return
}
_ = gotenv.Load(".env")
_ = gotenv.Load("../.env")
_ = gotenv.Load("../../.env")
if configPath != "" {
dir := filepath.Dir(configPath)
if dir != "." && dir != "" {
_ = gotenv.Load(filepath.Join(dir, ".env"))
_ = gotenv.Load(filepath.Join(dir, "..", ".env"))
}
}
}
func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("language", "zh-CN")
v.SetDefault("server.port", 8083)
v.SetDefault("server.companyName", "")
v.SetDefault("server.companyLogoUrl", "")
v.SetDefault("server.cors.allowedOrigins", []string{})
v.SetDefault("server.trustedProxies", []string{})
v.SetDefault("server.trustedPlatform", "")
v.SetDefault("server.rateLimit.windowSeconds", 60)
v.SetDefault("db.type", "sqlite")
v.SetDefault("db.dsn", "file:./data/app.db?_busy_timeout=5000")
v.SetDefault("db.maxIdleConns", 5)
v.SetDefault("db.maxOpenConns", 20)
v.SetDefault("db.connMaxIdleTimeSeconds", 300)
v.SetDefault("db.connMaxLifetimeSeconds", 1800)
v.SetDefault("logger.level", "info")
v.SetDefault("logger.format", "text")
v.SetDefault("logger.addSource", false)
v.SetDefault("auth.tokenTTLHours", 12)
v.SetDefault("auth.maxFailedAttempts", 5)
v.SetDefault("auth.maxFailedAttemptsPerIP", 0)
v.SetDefault("auth.credentialLockMinute", 15)
v.SetDefault("customerSession.ttlMinutes", 120)
v.SetDefault("customerSession.refreshThresholdMinutes", 30)
// Intentionally no default: an empty identity secret disables signed visitor
// identities rather than silently trusting unsigned ones.
v.SetDefault("identity.secret", "")
v.SetDefault("identity.maxAgeMinutes", 1440)
v.SetDefault("storage.default", "local")
v.SetDefault("storage.maxUploadSizeMB", 20)
v.SetDefault("storage.local.root", "data/storage")
v.SetDefault("storage.local.baseUrl", "/storage")
v.SetDefault("vectorDB.type", "qdrant")
v.SetDefault("vectorDB.qdrant.host", "127.0.0.1")
v.SetDefault("vectorDB.qdrant.grpcPort", 6334)
v.SetDefault("mcp.enabled", true)
v.SetDefault("discord.clientId", "")
v.SetDefault("discord.clientSecret", "")
v.SetDefault("discord.botToken", "")
v.SetDefault("discord.publicKey", "")
v.SetDefault("slack.clientId", "")
v.SetDefault("slack.clientSecret", "")
v.SetDefault("slack.botToken", "")
v.SetDefault("slack.signingSecret", "")
v.SetDefault("email.provider", "smtp")
v.SetDefault("email.fromAddress", "")
v.SetDefault("email.fromName", "")
v.SetDefault("email.apiKey", "")
v.SetDefault("email.smtpHost", "")
v.SetDefault("email.smtpPort", 587)
v.SetDefault("email.smtpUser", "")
v.SetDefault("email.smtpPassword", "")
v.SetDefault("email.smtpUseTls", false)
v.SetDefault("email.inboundSecret", "")
}
func bindEnvironmentAliases(v *viper.Viper) {
// Prefixed AGENT_DESK_* aliases are listed first so that ambient legacy
// variables (PORT, DATABASE_URL, ...) cannot silently override the
// documented configuration.
_ = v.BindEnv("server.port", "AGENT_DESK_SERVER_PORT", "PORT", "SERVER_PORT")
_ = v.BindEnv("server.companyName", "AGENT_DESK_SERVER_COMPANYNAME", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME")
_ = v.BindEnv("server.companyLogoUrl", "AGENT_DESK_SERVER_COMPANYLOGOURL", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL")
_ = v.BindEnv("server.trustedProxies", "AGENT_DESK_SERVER_TRUSTEDPROXIES", "TRUSTED_PROXIES")
_ = v.BindEnv("server.trustedPlatform", "AGENT_DESK_SERVER_TRUSTEDPLATFORM", "TRUSTED_PLATFORM")
_ = v.BindEnv("server.rateLimit.enabled", "AGENT_DESK_SERVER_RATELIMIT_ENABLED", "RATE_LIMIT_ENABLED")
_ = v.BindEnv("server.rateLimit.windowSeconds", "AGENT_DESK_SERVER_RATELIMIT_WINDOWSECONDS", "RATE_LIMIT_WINDOW_SECONDS")
_ = v.BindEnv("db.type", "AGENT_DESK_DB_TYPE", "DATABASE_TYPE", "DB_TYPE")
_ = v.BindEnv("db.dsn", "AGENT_DESK_DB_DSN", "DATABASE_URL", "DB_DSN")
_ = v.BindEnv("auth.passwordLoginEnabled", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED", "PASSWORD_LOGIN_ENABLED")
_ = v.BindEnv("auth.tokenTTLHours", "AGENT_DESK_AUTH_TOKENTTLHOURS", "AUTH_TOKEN_TTL_HOURS")
_ = v.BindEnv("customerSession.secret", "AGENT_DESK_CUSTOMERSESSION_SECRET", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET")
_ = v.BindEnv("identity.secret", "AGENT_DESK_IDENTITY_SECRET", "AGENT_DESK_IDENTITY_HMAC_SECRET", "IDENTITY_HMAC_SECRET", "WIDGET_IDENTITY_SECRET")
_ = v.BindEnv("identity.maxAgeMinutes", "AGENT_DESK_IDENTITY_MAXAGEMINUTES", "IDENTITY_SIGNATURE_MAX_AGE_MINUTES")
_ = v.BindEnv("conversation.customerMaxOpen", "AGENT_DESK_CONVERSATION_CUSTOMERMAXOPEN", "CONVERSATION_CUSTOMER_MAX_OPEN")
_ = v.BindEnv("conversation.emailChatLiveMinutes", "AGENT_DESK_CONVERSATION_EMAILCHATLIVEMINUTES", "CONVERSATION_EMAIL_CHAT_LIVE_MINUTES")
_ = v.BindEnv("storage.default", "AGENT_DESK_STORAGE_DEFAULT", "STORAGE_DEFAULT", "STORAGE_TYPE")
_ = v.BindEnv("storage.local.root", "AGENT_DESK_STORAGE_LOCAL_ROOT", "STORAGE_LOCAL_ROOT")
_ = v.BindEnv("storage.local.baseUrl", "AGENT_DESK_STORAGE_LOCAL_BASEURL", "STORAGE_LOCAL_BASE_URL")
_ = v.BindEnv("vectorDB.type", "AGENT_DESK_VECTORDB_TYPE", "VECTOR_DB_TYPE")
_ = v.BindEnv("vectorDB.qdrant.host", "AGENT_DESK_VECTORDB_QDRANT_HOST", "QDRANT_HOST")
_ = v.BindEnv("vectorDB.qdrant.grpcPort", "AGENT_DESK_VECTORDB_QDRANT_GRPCPORT", "QDRANT_GRPC_PORT", "QDRANT_PORT")
_ = v.BindEnv("vectorDB.qdrant.apiKey", "AGENT_DESK_VECTORDB_QDRANT_APIKEY", "QDRANT_API_KEY")
_ = v.BindEnv("oidc.enabled", "AGENT_DESK_OIDC_ENABLED", "OIDC_ENABLED")
_ = v.BindEnv("oidc.issuer", "AGENT_DESK_OIDC_ISSUER", "OIDC_ISSUER")
_ = v.BindEnv("oidc.clientId", "AGENT_DESK_OIDC_CLIENTID", "OIDC_CLIENT_ID", "CUSTOM_OAUTH_CLIENT_ID")
_ = v.BindEnv("oidc.clientSecret", "AGENT_DESK_OIDC_CLIENTSECRET", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET")
_ = v.BindEnv("oidc.redirectUrl", "AGENT_DESK_OIDC_REDIRECTURL", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI")
_ = v.BindEnv("webhook.orgSyncSecret", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET", "ORG_SYNC_SECRET", "WEBHOOK_SECRET")
_ = v.BindEnv("discord.clientId", "AGENT_DESK_DISCORD_CLIENTID", "DISCORD_CLIENT_ID")
_ = v.BindEnv("discord.clientSecret", "AGENT_DESK_DISCORD_CLIENTSECRET", "DISCORD_CLIENT_SECRET")
_ = v.BindEnv("discord.botToken", "AGENT_DESK_DISCORD_BOTTOKEN", "DISCORD_BOT_TOKEN")
_ = v.BindEnv("discord.publicKey", "AGENT_DESK_DISCORD_PUBLICKEY", "DISCORD_PUBLIC_KEY")
_ = v.BindEnv("slack.clientId", "AGENT_DESK_SLACK_CLIENTID", "SLACK_CLIENT_ID")
_ = v.BindEnv("slack.clientSecret", "AGENT_DESK_SLACK_CLIENTSECRET", "SLACK_CLIENT_SECRET")
_ = v.BindEnv("slack.botToken", "AGENT_DESK_SLACK_BOTTOKEN", "SLACK_BOT_TOKEN")
_ = v.BindEnv("slack.signingSecret", "AGENT_DESK_SLACK_SIGNINGSECRET", "SLACK_SIGNING_SECRET")
_ = v.BindEnv("email.provider", "EMAIL_PROVIDER", "AGENT_DESK_EMAIL_PROVIDER")
_ = v.BindEnv("email.fromAddress", "EMAIL_FROM", "EMAIL_FROM_ADDRESS", "AGENT_DESK_EMAIL_FROMADDRESS")
_ = v.BindEnv("email.fromName", "EMAIL_FROM_NAME", "EMAIL_SENDER_NAME", "AGENT_DESK_EMAIL_FROMNAME")
_ = v.BindEnv("email.apiKey", "EMAIL_API_KEY", "BREVO_API_KEY", "AGENT_DESK_EMAIL_APIKEY")
_ = v.BindEnv("email.smtpHost", "SMTP_HOST", "EMAIL_SMTP_HOST", "AGENT_DESK_EMAIL_SMTPHOST")
_ = v.BindEnv("email.smtpPort", "SMTP_PORT", "EMAIL_SMTP_PORT", "AGENT_DESK_EMAIL_SMTPPORT")
_ = v.BindEnv("email.smtpUser", "SMTP_USER", "EMAIL_SMTP_USER", "AGENT_DESK_EMAIL_SMTPUSER")
_ = v.BindEnv("email.smtpPassword", "SMTP_PASSWORD", "SMTP_PASS", "AGENT_DESK_EMAIL_SMTPPASSWORD")
_ = v.BindEnv("email.smtpUseTls", "SMTP_USE_TLS", "SMTP_SSL", "AGENT_DESK_EMAIL_SMTPUSETLS")
_ = v.BindEnv("email.smtpAllowInsecure", "SMTP_ALLOW_INSECURE", "AGENT_DESK_EMAIL_SMTPALLOWINSECURE")
_ = v.BindEnv("email.inboundSecret", "EMAIL_INBOUND_SECRET", "EMAIL_WEBHOOK_SECRET", "AGENT_DESK_EMAIL_INBOUNDSECRET")
}
func normalizeLoadedConfig(cfg *Config) {
if cfg == nil {
return
}
if cfg.DB.Type == "sqlite" && (strings.HasPrefix(cfg.DB.DSN, "postgres://") || strings.HasPrefix(cfg.DB.DSN, "postgresql://")) {
cfg.DB.Type = "postgres"
} else if cfg.DB.Type == "sqlite" && strings.Contains(cfg.DB.DSN, "@tcp(") {
cfg.DB.Type = "mysql"
}
}

View File

@@ -0,0 +1,313 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadReadsCORSAllowedOrigins(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
content := []byte(`server:
port: 8083
cors:
allowedOrigins:
- https://console.example.com
- http://localhost:3000
`)
if err := os.WriteFile(path, content, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
got := cfg.Server.CORS.AllowedOrigins
want := []string{"https://console.example.com", "http://localhost:3000"}
if len(got) != len(want) {
t.Fatalf("len(AllowedOrigins)=%d want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("AllowedOrigins[%d]=%q want %q", i, got[i], want[i])
}
}
}
func TestLoadOverridesValuesFromEnvironment(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
content := []byte(`server:
port: 8083
db:
type: sqlite
dsn: file:./data/app.db?_busy_timeout=5000
storage:
local:
baseUrl: /storage
mcp:
servers:
system:
endpoint: http://127.0.0.1:8083/api/mcp
`)
if err := os.WriteFile(path, content, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv("AGENT_DESK_SERVER_PORT", "8090")
t.Setenv("AGENT_DESK_DB_DSN", "mysql-dsn")
t.Setenv("AGENT_DESK_STORAGE_LOCAL_BASEURL", "/files")
t.Setenv("AGENT_DESK_MCP_SERVERS_SYSTEM_ENDPOINT", "http://127.0.0.1:8090/api/mcp")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Server.Port != 8090 {
t.Fatalf("Server.Port=%d want 8090", cfg.Server.Port)
}
if cfg.DB.Type != "sqlite" {
t.Fatalf("DB.Type=%q want sqlite", cfg.DB.Type)
}
if cfg.DB.DSN != "mysql-dsn" {
t.Fatalf("DB.DSN=%q want mysql-dsn", cfg.DB.DSN)
}
if cfg.Storage.Local.BaseURL != "/files" {
t.Fatalf("Storage.Local.BaseURL=%q want /files", cfg.Storage.Local.BaseURL)
}
if cfg.MCP.Servers["system"].Endpoint != "http://127.0.0.1:8090/api/mcp" {
t.Fatalf("MCP system endpoint=%q", cfg.MCP.Servers["system"].Endpoint)
}
}
func TestLoadFromDotEnvAndStandardEnvAliases(t *testing.T) {
tempDir := t.TempDir()
envPath := filepath.Join(tempDir, ".env")
envContent := []byte(`PORT=9090
COMPANY_NAME=CustomDesk
COMPANY_LOGO_URL=/custom-logo.svg
DATABASE_URL=postgres://user:pass@localhost:5432/mydb?sslmode=disable
PASSWORD_LOGIN_ENABLED=false
JWT_SECRET=super-secret-key-12345
QDRANT_HOST=10.0.0.5
QDRANT_PORT=6334
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=client-123
OIDC_CLIENT_SECRET=secret-456
OIDC_REDIRECT_URL=https://desk.example.com/api/auth/oidc_callback
ORG_SYNC_SECRET=webhook-secret-789
`)
if err := os.WriteFile(envPath, envContent, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv("ENV_FILE", envPath)
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Server.Port != 9090 {
t.Fatalf("Server.Port=%d want 9090", cfg.Server.Port)
}
if cfg.Server.CompanyName != "CustomDesk" {
t.Fatalf("Server.CompanyName=%q want CustomDesk", cfg.Server.CompanyName)
}
if cfg.Server.CompanyLogoURL != "/custom-logo.svg" {
t.Fatalf("Server.CompanyLogoURL=%q want /custom-logo.svg", cfg.Server.CompanyLogoURL)
}
if cfg.DB.Type != "postgres" {
t.Fatalf("DB.Type=%q want postgres", cfg.DB.Type)
}
if cfg.DB.DSN != "postgres://user:pass@localhost:5432/mydb?sslmode=disable" {
t.Fatalf("DB.DSN=%q", cfg.DB.DSN)
}
if cfg.Auth.IsPasswordLoginEnabled() {
t.Fatalf("expected PasswordLoginEnabled to be false")
}
if cfg.CustomerSession.Secret != "super-secret-key-12345" {
t.Fatalf("CustomerSession.Secret=%q", cfg.CustomerSession.Secret)
}
if cfg.VectorDB.Qdrant.Host != "10.0.0.5" {
t.Fatalf("Qdrant.Host=%q", cfg.VectorDB.Qdrant.Host)
}
if cfg.VectorDB.Qdrant.GrpcPort != 6334 {
t.Fatalf("Qdrant.GrpcPort=%d", cfg.VectorDB.Qdrant.GrpcPort)
}
if !cfg.OIDC.Enabled {
t.Fatalf("expected OIDC.Enabled=true")
}
if cfg.OIDC.Issuer != "https://auth.example.com" {
t.Fatalf("OIDC.Issuer=%q", cfg.OIDC.Issuer)
}
if cfg.OIDC.ClientID != "client-123" {
t.Fatalf("OIDC.ClientID=%q", cfg.OIDC.ClientID)
}
if cfg.OIDC.ClientSecret != "secret-456" {
t.Fatalf("OIDC.ClientSecret=%q", cfg.OIDC.ClientSecret)
}
if cfg.OIDC.RedirectURL != "https://desk.example.com/api/auth/oidc_callback" {
t.Fatalf("OIDC.RedirectURL=%q", cfg.OIDC.RedirectURL)
}
if cfg.Webhook.OrgSyncSecret != "webhook-secret-789" {
t.Fatalf("Webhook.OrgSyncSecret=%q", cfg.Webhook.OrgSyncSecret)
}
}
func TestAuthConfigMaxFailedAttemptsPerIPOrDefault(t *testing.T) {
cases := []struct {
name string
cfg AuthConfig
want int
}{
{"explicit value wins", AuthConfig{MaxFailedAttempts: 5, MaxFailedAttemptsPerIP: 30}, 30},
{"unset derives four times the per-account limit", AuthConfig{MaxFailedAttempts: 5}, 20},
{"disabled per-account limit disables both", AuthConfig{MaxFailedAttempts: 0}, 0},
{"negative per-account limit disables both", AuthConfig{MaxFailedAttempts: -1}, 0},
}
for _, tc := range cases {
if got := tc.cfg.MaxFailedAttemptsPerIPOrDefault(); got != tc.want {
t.Errorf("%s: MaxFailedAttemptsPerIPOrDefault() = %d want %d", tc.name, got, tc.want)
}
}
}
func TestServerConfigTrustedProxiesOrDefault(t *testing.T) {
// An unset list must not fall through to Gin's own default of 0.0.0.0/0 and
// ::/0, which trusts every peer and makes X-Forwarded-For authoritative.
got := ServerConfig{}.TrustedProxiesOrDefault()
for _, want := range []string{"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"} {
found := false
for _, entry := range got {
if entry == want {
found = true
}
}
if !found {
t.Errorf("TrustedProxiesOrDefault() = %v, missing %s", got, want)
}
}
for _, entry := range got {
if entry == "0.0.0.0/0" || entry == "::/0" {
t.Errorf("TrustedProxiesOrDefault() includes %s, which trusts every peer", entry)
}
}
// Blank entries from a comma-separated environment variable must not become
// empty CIDRs, which Gin would reject at startup.
got = ServerConfig{TrustedProxies: []string{" 10.1.0.0/16 ", "", " "}}.TrustedProxiesOrDefault()
if len(got) != 1 || got[0] != "10.1.0.0/16" {
t.Errorf("TrustedProxiesOrDefault() = %v want [10.1.0.0/16]", got)
}
}
func TestServerConfigTrustedPlatformHeader(t *testing.T) {
cases := []struct {
platform string
want string
}{
{"", ""},
{"cloudflare", "CF-Connecting-IP"},
{"Cloudflare", "CF-Connecting-IP"},
{" CF ", "CF-Connecting-IP"},
{"fly.io", "Fly-Client-IP"},
{"google-app-engine", "X-Appengine-Remote-Addr"},
// Anything unrecognised is a literal header name, which is what Gin's
// TrustedPlatform field expects.
{"X-CDN-IP", "X-CDN-IP"},
}
for _, tc := range cases {
if got := (ServerConfig{TrustedPlatform: tc.platform}).TrustedPlatformHeader(); got != tc.want {
t.Errorf("TrustedPlatformHeader(%q) = %q want %q", tc.platform, got, tc.want)
}
}
}
// TestLoadReadsTrustedProxySettings covers the environment spelling, including
// the comma-separated list. Gin rejects an unparseable CIDR at startup, so a
// value that arrives as one string instead of a slice would take the process
// down rather than degrade quietly.
func TestLoadReadsTrustedProxySettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("server:\n port: 8083\n"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv("ENV_FILE", os.DevNull)
t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull)
t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8,172.16.0.0/12")
t.Setenv("TRUSTED_PLATFORM", "cloudflare")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
want := []string{"10.0.0.0/8", "172.16.0.0/12"}
got := cfg.Server.TrustedProxies
if len(got) != len(want) {
t.Fatalf("TrustedProxies = %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("TrustedProxies[%d] = %q want %q", i, got[i], want[i])
}
}
if cfg.Server.TrustedPlatformHeader() != "CF-Connecting-IP" {
t.Errorf("TrustedPlatformHeader() = %q want CF-Connecting-IP", cfg.Server.TrustedPlatformHeader())
}
}
func TestRateLimitConfigDefaultsToEnabledWithAMinuteWindow(t *testing.T) {
var cfg RateLimitConfig
if !cfg.IsEnabled() {
t.Error("rate limiting must default to enabled; a zero-value config should not silently turn protection off")
}
if got := cfg.WindowSecondsOrDefault(); got != 60 {
t.Errorf("WindowSecondsOrDefault() = %d want 60", got)
}
off := false
cfg = RateLimitConfig{Enabled: &off, WindowSeconds: -5}
if cfg.IsEnabled() {
t.Error("Enabled=false must disable the limits")
}
if got := cfg.WindowSecondsOrDefault(); got != 60 {
t.Errorf("a non-positive window must fall back to 60, got %d", got)
}
on := true
cfg = RateLimitConfig{Enabled: &on, WindowSeconds: 30}
if !cfg.IsEnabled() {
t.Error("Enabled=true must enable the limits")
}
if got := cfg.WindowSecondsOrDefault(); got != 30 {
t.Errorf("WindowSecondsOrDefault() = %d want 30", got)
}
}
func TestLoadReadsRateLimitSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("server:\n port: 8083\n"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv("ENV_FILE", os.DevNull)
t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull)
t.Setenv("RATE_LIMIT_ENABLED", "false")
t.Setenv("RATE_LIMIT_WINDOW_SECONDS", "30")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Server.RateLimit.IsEnabled() {
t.Error("RATE_LIMIT_ENABLED=false did not disable the limits")
}
if got := cfg.Server.RateLimit.WindowSecondsOrDefault(); got != 30 {
t.Errorf("WindowSecondsOrDefault() = %d want 30", got)
}
}

View File

@@ -0,0 +1,24 @@
package config
import "testing"
func TestConversationMaxOpen(t *testing.T) {
tests := []struct {
name string
config ConversationConfig
want int
}{
{name: "unset falls back to a cap", config: ConversationConfig{}, want: 3},
{name: "explicit zero also means the default", config: ConversationConfig{CustomerMaxOpen: 0}, want: 3},
{name: "explicit value wins", config: ConversationConfig{CustomerMaxOpen: 7}, want: 7},
{name: "negative removes the cap", config: ConversationConfig{CustomerMaxOpen: -1}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.config.MaxOpen(); got != tt.want {
t.Fatalf("MaxOpen() = %d, want %d", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,31 @@
package config
import "testing"
func TestLanguageOrDefault(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "blank falls back", in: "", want: "en-US"},
{name: "unsupported falls back", in: "fr-FR", want: "en-US"},
{name: "chinese", in: "zh-CN", want: "zh-CN"},
{name: "english", in: "en", want: "en-US"},
{name: "english underscore", in: "EN_us", want: "en-US"},
{name: "russian", in: "ru-RU", want: "ru-RU"},
{name: "russian short", in: "ru", want: "ru-RU"},
{name: "russian underscore", in: "ru_ru", want: "ru-RU"},
{name: "russian padded", in: " ru-RU ", want: "ru-RU"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
cfg := Config{Language: tt.in}
if got := cfg.LanguageOrDefault(); got != tt.want {
t.Fatalf("LanguageOrDefault(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,32 @@
package config
import "strings"
var current *Config
func SetCurrent(cfg *Config) {
current = cfg
}
func Current() Config {
if current == nil {
panic("config not initialized")
}
return *current
}
func GetCurrent() *Config {
return current
}
// ResolveSlack resolves the Slack app credentials for one channel without
// requiring the caller to nil-check the loaded configuration.
func ResolveSlack(channelBotToken, channelSigningSecret string) SlackConfig {
if current == nil {
return SlackConfig{
BotToken: strings.TrimSpace(channelBotToken),
SigningSecret: strings.TrimSpace(channelSigningSecret),
}
}
return current.SlackApp(channelBotToken, channelSigningSecret)
}

View File

@@ -0,0 +1,44 @@
package config
import "testing"
func TestSlackAppChannelValuesWinOverDeployment(t *testing.T) {
cfg := Config{Slack: SlackConfig{
ClientID: "deploy-client-id",
ClientSecret: "deploy-client-secret",
BotToken: "xoxb-deploy",
SigningSecret: "deploy-signing-secret",
}}
app := cfg.SlackApp("xoxb-channel", "channel-signing-secret")
if app.ClientID != "deploy-client-id" || app.ClientSecret != "deploy-client-secret" {
t.Fatalf("the app itself is deployment-wide, got %+v", app)
}
if app.BotToken != "xoxb-channel" {
t.Fatalf("channel bot token must win over the deployment one, got %q", app.BotToken)
}
if app.SigningSecret != "channel-signing-secret" {
t.Fatalf("channel signing secret must win over the deployment one, got %q", app.SigningSecret)
}
fallback := cfg.SlackApp(" ", "")
if fallback.BotToken != "xoxb-deploy" || fallback.SigningSecret != "deploy-signing-secret" {
t.Fatalf("blank channel values must fall back to the deployment app, got %+v", fallback)
}
}
// ResolveSlack must stay usable before Load has run (tests, standalone
// commands): the channel values pass through and nothing panics.
func TestResolveSlackWithoutLoadedConfig(t *testing.T) {
saved := GetCurrent()
defer SetCurrent(saved)
SetCurrent(nil)
app := ResolveSlack("xoxb-channel", "channel-signing-secret")
if app.BotToken != "xoxb-channel" || app.SigningSecret != "channel-signing-secret" {
t.Fatalf("expected channel values to pass through, got %+v", app)
}
if app.ClientID != "" || app.ClientSecret != "" {
t.Fatalf("no deployment app exists before Load, got %+v", app)
}
}

View File

@@ -0,0 +1,45 @@
package config
import (
"testing"
"gopkg.in/yaml.v3"
)
func TestVectorDBConfigUnmarshalNestedProviders(t *testing.T) {
raw := []byte(`
vectorDB:
type: lancedb
qdrant:
host: 127.0.0.1
grpcPort: 6334
apiKey: secret
useTls: true
lancedb:
path: data/lancedb
`)
var cfg Config
if err := yaml.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("yaml.Unmarshal() error = %v", err)
}
if cfg.VectorDB.Type != "lancedb" {
t.Fatalf("VectorDB.Type = %q, want %q", cfg.VectorDB.Type, "lancedb")
}
if cfg.VectorDB.Qdrant.Host != "127.0.0.1" {
t.Fatalf("VectorDB.Qdrant.Host = %q, want %q", cfg.VectorDB.Qdrant.Host, "127.0.0.1")
}
if cfg.VectorDB.Qdrant.GrpcPort != 6334 {
t.Fatalf("VectorDB.Qdrant.GrpcPort = %d, want %d", cfg.VectorDB.Qdrant.GrpcPort, 6334)
}
if cfg.VectorDB.Qdrant.APIKey != "secret" {
t.Fatalf("VectorDB.Qdrant.APIKey = %q, want %q", cfg.VectorDB.Qdrant.APIKey, "secret")
}
if !cfg.VectorDB.Qdrant.UseTLS {
t.Fatal("VectorDB.Qdrant.UseTLS = false, want true")
}
if cfg.VectorDB.LanceDB.Path != "data/lancedb" {
t.Fatalf("VectorDB.LanceDB.Path = %q, want %q", cfg.VectorDB.LanceDB.Path, "data/lancedb")
}
}