package channel import ( "context" "encoding/json" "fmt" "regexp" "strconv" applogger "github.com/gochat/gochat/pkg/logger" ) // --- ConfigValidator: validates ChannelConfig against provider ConfigSchemaDefinition --- // Reference: P2D §7 — Chatwoot's per-channel before_validation callbacks // Uses ConfigSchemaDefinition required fields, type checking, pattern/format validation, enum validation. // ConfigValidator validates a ChannelConfig against a provider's ConfigSchemaDefinition. type ConfigValidator struct{} // NewConfigValidator creates a new ConfigValidator instance. func NewConfigValidator() *ConfigValidator { return &ConfigValidator{} } // ValidateConfig validates ChannelConfig against the provider's schema. // Checks required fields, type checking, pattern/format validation, and enum validation. func ValidateConfig(ctx context.Context, provider ChannelProvider, config ChannelConfig) error { if provider == nil { return fmt.Errorf("channel provider is nil") } if config == nil { return fmt.Errorf("channel config is nil") } schema := provider.ConfigSchema() if schema == nil { // No schema defined — accept any config return nil } v := NewConfigValidator() return v.validate(ctx, schema, config) } // validate performs the actual validation logic against the schema. func (v *ConfigValidator) validate(ctx context.Context, schema *ConfigSchemaDefinition, config ChannelConfig) error { // Check required fields for _, requiredKey := range schema.Required { val, exists := config[requiredKey] if !exists || val == nil { return fmt.Errorf("required config field \"%s\" is missing", requiredKey) } } // Validate each present field against its property definition for key, value := range config { prop, hasProp := schema.Properties[key] if !hasProp { // Unknown field — allow it (extra fields are tolerated) continue } if err := v.validateProperty(ctx, key, prop, value); err != nil { return err } } return nil } // validateProperty checks a single config value against its ConfigProperty definition. func (v *ConfigValidator) validateProperty(ctx context.Context, key string, prop ConfigProperty, value interface{}) error { if value == nil { // Nil values are only invalid for required fields (already checked above) return nil } // Type checking if prop.Type != "" { if err := v.validateType(key, prop.Type, value); err != nil { return err } } // Enum validation if len(prop.Enum) > 0 { if err := v.validateEnum(key, prop.Enum, value); err != nil { return err } } // Pattern validation (regex, for string fields) if prop.Pattern != "" { if err := v.validatePattern(key, prop.Pattern, value); err != nil { return err } } // Format validation (url, email, uri, etc.) if prop.Format != "" { if err := v.validateFormat(key, prop.Format, value); err != nil { return err } } return nil } // validateType checks that the value matches the declared JSON Schema type. func (v *ConfigValidator) validateType(key string, expectedType string, value interface{}) error { switch expectedType { case "string": if _, ok := value.(string); !ok { return fmt.Errorf("config field \"%s\" must be a string, got %T", key, value) } case "number": switch value.(type) { case int, int8, int16, int32, int64, float32, float64: // OK default: // JSON numbers can also arrive as string-encoded numbers via map[string]interface{} if s, ok := value.(string); ok { if _, err := strconv.ParseFloat(s, 64); err != nil { return fmt.Errorf("config field \"%s\" must be a number, got string \"%s\"", key, s) } } else { return fmt.Errorf("config field \"%s\" must be a number, got %T", key, value) } } case "integer": switch value.(type) { case int, int8, int16, int32, int64: // OK default: if s, ok := value.(string); ok { if _, err := strconv.ParseInt(s, 10, 64); err != nil { return fmt.Errorf("config field \"%s\" must be an integer, got string \"%s\"", key, s) } } else { return fmt.Errorf("config field \"%s\" must be an integer, got %T", key, value) } } case "boolean": if _, ok := value.(bool); !ok { return fmt.Errorf("config field \"%s\" must be a boolean, got %T", key, value) } case "array": // JSON arrays decode as []interface{} in map[string]interface{} switch value.(type) { case []interface{}: // OK case []string, []int, []float64: // Also OK — typed slices are valid default: return fmt.Errorf("config field \"%s\" must be an array, got %T", key, value) } case "object": // JSON objects decode as map[string]interface{} switch value.(type) { case map[string]interface{}: // OK case ChannelConfig: // OK — ChannelConfig is map[string]interface{} default: return fmt.Errorf("config field \"%s\" must be an object, got %T", key, value) } default: // Unknown type declaration — skip type checking applogger.L().Debugf("unknown schema type \"%s\" for config field \"%s\", skipping type validation", expectedType, key) } return nil } // validateEnum checks that the value is one of the allowed enum values. func (v *ConfigValidator) validateEnum(key string, allowedValues []string, value interface{}) error { strVal, ok := value.(string) if !ok { // Enum validation applies to string fields; non-string values can't match enum return fmt.Errorf("config field \"%s\" has enum constraint but value is not a string (got %T)", key, value) } for _, allowed := range allowedValues { if strVal == allowed { return nil } } return fmt.Errorf("config field \"%s\" value \"%s\" is not one of allowed values: %v", key, strVal, allowedValues) } // validatePattern checks that a string value matches the given regex pattern. func (v *ConfigValidator) validatePattern(key string, pattern string, value interface{}) error { strVal, ok := value.(string) if !ok { return fmt.Errorf("config field \"%s\" has pattern constraint but value is not a string (got %T)", key, value) } matched, err := regexp.MatchString(pattern, strVal) if err != nil { return fmt.Errorf("config field \"%s\" has invalid regex pattern \"%s\": %w", key, pattern, err) } if !matched { return fmt.Errorf("config field \"%s\" value \"%s\" does not match pattern \"%s\"", key, strVal, pattern) } return nil } // validateFormat checks that a string value conforms to the specified format. func (v *ConfigValidator) validateFormat(key string, format string, value interface{}) error { strVal, ok := value.(string) if !ok { return fmt.Errorf("config field \"%s\" has format constraint but value is not a string (got %T)", key, value) } switch format { case "url", "uri": if err := v.validateURLFormat(key, strVal); err != nil { return err } case "email": if err := v.validateEmailFormat(key, strVal); err != nil { return err } default: // Unknown format — skip validation applogger.L().Debugf("unknown schema format \"%s\" for config field \"%s\", skipping format validation", format, key) } return nil } // validateURLFormat checks that the value looks like a valid URL. func (v *ConfigValidator) validateURLFormat(key string, value string) error { urlPattern := `^https?://.+` matched, err := regexp.MatchString(urlPattern, value) if err != nil { return fmt.Errorf("config field \"%s\" URL format validation error: %w", key, err) } if !matched { return fmt.Errorf("config field \"%s\" value \"%s\" is not a valid URL", key, value) } return nil } // validateEmailFormat checks that the value looks like a valid email. func (v *ConfigValidator) validateEmailFormat(key string, value string) error { emailPattern := `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` matched, err := regexp.MatchString(emailPattern, value) if err != nil { return fmt.Errorf("config field \"%s\" email format validation error: %w", key, err) } if !matched { return fmt.Errorf("config field \"%s\" value \"%s\" is not a valid email", key, value) } return nil } // --- MergeDefaults: merge provider default values into ChannelConfig --- // Reference: P2D §7 — Chatwoot's per-channel default attributes // MergeDefaults merges the provider's ConfigSchemaDefinition default values into the given config. // Fields already present in config are not overwritten; only missing fields with defaults are added. func MergeDefaults(ctx context.Context, provider ChannelProvider, config ChannelConfig) ChannelConfig { if provider == nil || config == nil { if config == nil { config = ChannelConfig{} } return config } schema := provider.ConfigSchema() if schema == nil { return config } // Start with a copy so we don't mutate the original result := ChannelConfig{} for k, v := range config { result[k] = v } // Merge defaults from schema properties for key, prop := range schema.Properties { if _, exists := result[key]; !exists && prop.Default != nil { result[key] = prop.Default } } return result } // --- SanitizeConfig: remove secret fields for API responses --- // Reference: P2D §7 — Chatwoot does not expose bot_token / api_key in API responses // SanitizeConfig removes secret fields from the config for API response rendering. // Returns a new ChannelConfig without fields marked as Secret in the provider's schema. func SanitizeConfig(ctx context.Context, provider ChannelProvider, config ChannelConfig) ChannelConfig { if provider == nil || config == nil { if config == nil { return ChannelConfig{} } return config } schema := provider.ConfigSchema() if schema == nil { // No schema — cannot determine secret fields; return copy without secrets heuristic return filterKnownSecrets(config) } result := ChannelConfig{} for k, v := range config { prop, hasProp := schema.Properties[k] if hasProp && prop.Secret { // Mask secret fields: show placeholder instead of actual value result[k] = "***" } else { result[k] = v } } return result } // filterKnownSecrets is a fallback when no schema is available. // It uses common naming conventions to guess secret fields. func filterKnownSecrets(config ChannelConfig) ChannelConfig { result := ChannelConfig{} secretPatterns := []string{"token", "secret", "password", "key", "api_key", "access_token", "auth"} for k, v := range config { isSecret := false lowerKey := k for _, pattern := range secretPatterns { if lowerKey == pattern || containsSubstring(lowerKey, pattern) { isSecret = true break } } if isSecret { result[k] = "***" } else { result[k] = v } } return result } // containsSubstring checks if substr is contained in s (simple check). func containsSubstring(s string, substr string) bool { return len(s) >= len(substr) && s[len(s)-len(substr):] == substr || len(s) >= len(substr) && containsAnySubstring(s, substr) } func containsAnySubstring(s string, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // --- GetConfigValue: type-safe value extraction from ChannelConfig --- // Reference: P2D §7 — safe access to typed config fields // GetConfigValue extracts a value from ChannelConfig with type assertion. // Returns the value and an error if the key is missing or the type doesn't match expectedType. // If expectedType is empty string, only checks for key existence. func GetConfigValue(ctx context.Context, config ChannelConfig, key string) (interface{}, error) { if config == nil { return nil, fmt.Errorf("channel config is nil") } val, exists := config[key] if !exists { return nil, fmt.Errorf("config key \"%s\" not found", key) } if val == nil { return nil, fmt.Errorf("config key \"%s\" has nil value", key) } return val, nil } // GetConfigString extracts a string value from ChannelConfig. func GetConfigString(ctx context.Context, config ChannelConfig, key string) (string, error) { val, err := GetConfigValue(ctx, config, key) if err != nil { return "", err } str, ok := val.(string) if !ok { return "", fmt.Errorf("config key \"%s\" is not a string (got %T)", key, val) } return str, nil } // GetConfigInt extracts an integer value from ChannelConfig. func GetConfigInt(ctx context.Context, config ChannelConfig, key string) (int, error) { val, err := GetConfigValue(ctx, config, key) if err != nil { return 0, err } switch v := val.(type) { case int: return v, nil case int64: return int(v), nil case float64: return int(v), nil case json.Number: i, err := v.Int64() if err != nil { return 0, fmt.Errorf("config key \"%s\" cannot convert json.Number to int: %w", key, err) } return int(i), nil case string: i, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("config key \"%s\" cannot convert string to int: %w", key, err) } return i, nil default: return 0, fmt.Errorf("config key \"%s\" is not an integer (got %T)", key, val) } } // GetConfigBool extracts a boolean value from ChannelConfig. func GetConfigBool(ctx context.Context, config ChannelConfig, key string) (bool, error) { val, err := GetConfigValue(ctx, config, key) if err != nil { return false, err } b, ok := val.(bool) if !ok { return false, fmt.Errorf("config key \"%s\" is not a boolean (got %T)", key, val) } return b, nil } // GetConfigFloat extracts a float64 value from ChannelConfig. func GetConfigFloat(ctx context.Context, config ChannelConfig, key string) (float64, error) { val, err := GetConfigValue(ctx, config, key) if err != nil { return 0, err } switch v := val.(type) { case float64: return v, nil case int: return float64(v), nil case int64: return float64(v), nil case string: f, err := strconv.ParseFloat(v, 64) if err != nil { return 0, fmt.Errorf("config key \"%s\" cannot convert string to float: %w", key, err) } return f, nil default: return 0, fmt.Errorf("config key \"%s\" is not a number (got %T)", key, val) } } // GetConfigStringSlice extracts a []string value from ChannelConfig. func GetConfigStringSlice(ctx context.Context, config ChannelConfig, key string) ([]string, error) { val, err := GetConfigValue(ctx, config, key) if err != nil { return nil, err } switch v := val.(type) { case []string: return v, nil case []interface{}: result := make([]string, 0, len(v)) for i, item := range v { s, ok := item.(string) if !ok { return nil, fmt.Errorf("config key \"%s\" array element %d is not a string (got %T)", key, i, item) } result = append(result, s) } return result, nil default: return nil, fmt.Errorf("config key \"%s\" is not an array (got %T)", key, val) } }