Files
gochat/internal/channel/config.go
T
2026-06-04 15:44:48 +08:00

483 lines
14 KiB
Go

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