483 lines
14 KiB
Go
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)
|
|
}
|
|
} |