Files
agent-desk-ng/internal/pkg/config/config.go

589 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
}
}