feat: geolocate anonymous widget visitors
Build and publish Docker images / Build and publish images (push) Successful in 2m10s
Build and publish Docker images / Build and publish images (push) Successful in 2m10s
This commit is contained in:
@@ -3,7 +3,8 @@ server:
|
||||
mode: "release"
|
||||
|
||||
database:
|
||||
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
|
||||
dsn: >-
|
||||
postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable
|
||||
run_migrations: false
|
||||
migrations_path: "/app/migrations"
|
||||
|
||||
@@ -33,3 +34,7 @@ worker:
|
||||
storage:
|
||||
provider: "local"
|
||||
local_path: "/app/storage/uploads"
|
||||
|
||||
geoip:
|
||||
# Set GOCHAT_GEOIP_DB_PATH to the read-only MMDB mount in production.
|
||||
db_path: ""
|
||||
|
||||
+71
-25
@@ -8,21 +8,51 @@ server:
|
||||
idle_timeout_seconds: 120
|
||||
shutdown_timeout_seconds: 30
|
||||
max_header_bytes: 1048576
|
||||
trusted_proxies: [] # add explicit reverse-proxy IPs/CIDRs; XFF is ignored otherwise
|
||||
# Add explicit reverse-proxy IPs/CIDRs; XFF is ignored otherwise.
|
||||
trusted_proxies: []
|
||||
cors:
|
||||
allowed_origins: [] # retained for config compatibility; GoChat allows all origins
|
||||
allowed_methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
|
||||
allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash", "access-token", "client", "uid", "token-type", "expiry"]
|
||||
expose_headers: ["Content-Length", "access-token", "client", "uid", "token-type", "expiry"]
|
||||
allow_credentials: false # retained for config compatibility; wildcard CORS does not use credentials
|
||||
max_age: 86400 # preflight cache duration in seconds
|
||||
# Retained for config compatibility; GoChat allows all origins.
|
||||
allowed_origins: []
|
||||
allowed_methods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "PUT"
|
||||
- "DELETE"
|
||||
- "PATCH"
|
||||
- "OPTIONS"
|
||||
allowed_headers:
|
||||
- "Origin"
|
||||
- "Content-Type"
|
||||
- "Accept"
|
||||
- "Authorization"
|
||||
- "X-Account-ID"
|
||||
- "X-Auth-Token"
|
||||
- "X-Widget-Token"
|
||||
- "X-Identifier-Hash"
|
||||
- "access-token"
|
||||
- "client"
|
||||
- "uid"
|
||||
- "token-type"
|
||||
- "expiry"
|
||||
expose_headers:
|
||||
- "Content-Length"
|
||||
- "access-token"
|
||||
- "client"
|
||||
- "uid"
|
||||
- "token-type"
|
||||
- "expiry"
|
||||
# Retained for config compatibility; wildcard CORS does not use credentials.
|
||||
allow_credentials: false
|
||||
# Preflight cache duration in seconds.
|
||||
max_age: 86400
|
||||
|
||||
database:
|
||||
dsn: "postgres://postgres@localhost:5432/gochat_dev?sslmode=disable"
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
conn_max_lifetime: 3600 # seconds
|
||||
run_migrations: true # auto-run migrations on startup (dev convenience)
|
||||
# Auto-run migrations on startup (dev convenience).
|
||||
run_migrations: true
|
||||
migrations_path: "migrations"
|
||||
|
||||
redis:
|
||||
@@ -51,10 +81,18 @@ log:
|
||||
|
||||
rate_limit:
|
||||
enabled: true
|
||||
login: { requests: 10, window_seconds: 60 }
|
||||
password_reset: { requests: 5, window_seconds: 300 }
|
||||
public_upload: { requests: 20, window_seconds: 60 }
|
||||
webhook: { requests: 120, window_seconds: 60 }
|
||||
login:
|
||||
requests: 10
|
||||
window_seconds: 60
|
||||
password_reset:
|
||||
requests: 5
|
||||
window_seconds: 300
|
||||
public_upload:
|
||||
requests: 20
|
||||
window_seconds: 60
|
||||
webhook:
|
||||
requests: 120
|
||||
window_seconds: 60
|
||||
|
||||
search:
|
||||
# Chatwoot parity target. Use "db" only for explicit local fallback.
|
||||
@@ -64,19 +102,27 @@ search:
|
||||
index_prefix: "gochat_"
|
||||
timeout_seconds: 5
|
||||
|
||||
geoip:
|
||||
# Optional read-only MaxMind-compatible City database. Empty disables lookup.
|
||||
db_path: ""
|
||||
|
||||
saml:
|
||||
enabled: false # SAML 2.0 SSO — enable for enterprise IdP integration
|
||||
# IdP metadata: provide URL or inline XML (URL preferred for auto-refresh)
|
||||
idp_metadata_url: "" # e.g. "https://idp.example.com/metadata"
|
||||
idp_metadata_xml: "" # fallback: paste IdP metadata XML here
|
||||
sp_entity_id: "https://gochat.example.com/saml" # our SP entity ID
|
||||
acs_url: "https://gochat.example.com/api/v1/saml/acs" # Assertion Consumer Service URL
|
||||
# SP key/certificate: PEM format (required for signed AuthnRequest + response validation)
|
||||
sp_private_key: "" # path or inline PEM — generate with: openssl genrsa -out sp.key 2048
|
||||
sp_certificate: "" # path or inline PEM — generate with: openssl req -new -x509 -key sp.key -out sp.crt
|
||||
clock_drift_tolerance: 180 # seconds of allowed clock drift for NotOnOrAfter validation
|
||||
# IdP metadata: provide URL or inline XML (URL preferred for auto-refresh).
|
||||
idp_metadata_url: ""
|
||||
# Fallback: paste IdP metadata XML here.
|
||||
idp_metadata_xml: ""
|
||||
# Our SP entity ID.
|
||||
sp_entity_id: "https://gochat.example.com/saml"
|
||||
# Assertion Consumer Service URL.
|
||||
acs_url: "https://gochat.example.com/api/v1/saml/acs"
|
||||
# SP key/certificate is PEM format and required for signed AuthnRequest
|
||||
# and response validation.
|
||||
sp_private_key: ""
|
||||
sp_certificate: ""
|
||||
clock_drift_tolerance: 180 # seconds of allowed clock drift
|
||||
attribute_map:
|
||||
email: "email" # SAML attribute → GoChat email field
|
||||
display_name: "displayName" # SAML attribute → GoChat display name field
|
||||
first_name: "firstName" # SAML attribute → first name component
|
||||
last_name: "lastName" # SAML attribute → last name component
|
||||
email: "email"
|
||||
display_name: "displayName"
|
||||
first_name: "firstName"
|
||||
last_name: "lastName"
|
||||
|
||||
@@ -15,6 +15,7 @@ require (
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/oschwald/geoip2-golang v1.13.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/go-resty/resty/v2 v2.16.5
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
@@ -57,6 +58,7 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||
|
||||
@@ -248,6 +248,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gochat/gochat/internal/automation"
|
||||
"github.com/gochat/gochat/internal/canned"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/geoip"
|
||||
ws "github.com/gochat/gochat/internal/handler/ws"
|
||||
"github.com/gochat/gochat/internal/lifecycle"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
@@ -42,6 +43,7 @@ type App struct {
|
||||
eventPublisher *wspkg.EventPublisher
|
||||
notificationDeliverySvc *service.NotificationDeliveryService
|
||||
workerPool *worker.WorkerPool
|
||||
visitorGeoReader *geoip.Reader
|
||||
ready *atomic.Bool
|
||||
notificationRunning atomic.Bool
|
||||
handlerGroupOnce sync.Once
|
||||
@@ -64,6 +66,9 @@ func New(cfg *config.Config) (*App, error) {
|
||||
// Initialize gin engine
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
engine := gin.New()
|
||||
if err := engine.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil {
|
||||
return nil, fmt.Errorf("trusted proxy configuration failed: %w", err)
|
||||
}
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
// Initialize pub/sub — Redis-backed when Redis is configured, in-memory fallback
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/database"
|
||||
"github.com/gochat/gochat/internal/geoip"
|
||||
basehandler "github.com/gochat/gochat/internal/handler"
|
||||
v1 "github.com/gochat/gochat/internal/handler/api/v1"
|
||||
webhook "github.com/gochat/gochat/internal/handler/webhook"
|
||||
@@ -98,6 +99,16 @@ func Bootstrap(env string) (*App, error) {
|
||||
|
||||
applogger.L().Infof("Configuration loaded (env=%s, mode=%s)", env, cfg.Server.Mode)
|
||||
|
||||
var visitorGeoReader *geoip.Reader
|
||||
if path := strings.TrimSpace(cfg.GeoIP.DBPath); path != "" {
|
||||
visitorGeoReader, err = geoip.Open(path)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("GeoIP database unavailable at %s; anonymous visitors will use fallback names: %v", path, err)
|
||||
} else {
|
||||
applogger.L().Infof("GeoIP database opened: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Connect to PostgreSQL (ref: Chatwoot config/database.yml)
|
||||
db, err := NewDatabase(&cfg.Database, cfg.Log.Level)
|
||||
if err != nil {
|
||||
@@ -807,6 +818,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
widgetFileUploadRepo := repository.NewWidgetFileUploadRepo(db)
|
||||
widgetOfflineMessageRepo := repository.NewWidgetOfflineMessageRepo(db)
|
||||
widgetService := service.NewWidgetService(inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, widgetTypingAdapter, widgetThemeConfigRepo, preChatFormRepo, widgetFileUploadRepo, widgetOfflineMessageRepo, inboxMemberRepo, tagRepo, campaignRepo)
|
||||
widgetService.SetVisitorGeoResolver(visitorGeoReader)
|
||||
widgetService.SetWorkerPool(workerPool)
|
||||
widgetService.SetSearchIndexer(searchIndexer)
|
||||
widgetService.SetDispatcher(channelDispatcher)
|
||||
@@ -1048,6 +1060,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
eventPublisher: eventPublisher,
|
||||
notificationDeliverySvc: notificationDeliverySvc,
|
||||
workerPool: workerPool,
|
||||
visitorGeoReader: visitorGeoReader,
|
||||
ready: ready,
|
||||
}
|
||||
notificationDeliverySvc.SetHandlerGroup(application.handlers())
|
||||
|
||||
@@ -73,6 +73,16 @@ func (a *App) shutdown(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Close the shared local GeoIP reader after all HTTP handlers have drained.
|
||||
if a.visitorGeoReader != nil {
|
||||
if err := a.visitorGeoReader.Close(); err != nil {
|
||||
applogger.L().Errorf("GeoIP database close error: %v", err)
|
||||
shutdownErrs = append(shutdownErrs, err)
|
||||
} else {
|
||||
applogger.L().Info("GeoIP database closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Close PubSub — stop event publishing and consuming
|
||||
if a.pubsub != nil {
|
||||
if closer, ok := a.pubsub.(interface{ Close() error }); ok {
|
||||
|
||||
@@ -39,6 +39,7 @@ type Config struct {
|
||||
CSRF CSRFConfig `mapstructure:"csrf"`
|
||||
Session SessionConfig `mapstructure:"session"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
GeoIP GeoIPConfig `mapstructure:"geoip"`
|
||||
Copilot CopilotConfig `mapstructure:"copilot"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Encryption EncryptionConfig `mapstructure:"encryption"`
|
||||
@@ -280,6 +281,11 @@ type StorageConfig struct {
|
||||
MaxFileSize int64 `mapstructure:"max_file_size"` // Maximum file size in bytes (default 20MB)
|
||||
}
|
||||
|
||||
// GeoIPConfig configures the optional local MaxMind-compatible City database.
|
||||
type GeoIPConfig struct {
|
||||
DBPath string `mapstructure:"db_path"`
|
||||
}
|
||||
|
||||
// Load reads config from file and environment.
|
||||
func Load() (*Config, error) {
|
||||
viper.SetConfigName("config")
|
||||
@@ -310,6 +316,7 @@ func Load() (*Config, error) {
|
||||
viper.SetDefault("rate_limit.webhook.window_seconds", 60)
|
||||
viper.SetDefault("encryption.enabled", false)
|
||||
viper.SetDefault("encryption.current_key_version", 1)
|
||||
viper.SetDefault("geoip.db_path", "")
|
||||
|
||||
// Set defaults for push notifications
|
||||
viper.SetDefault("push.enabled", false)
|
||||
@@ -584,6 +591,7 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
"GOCHAT_STORAGE_PROVIDER": "storage.provider",
|
||||
"GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path",
|
||||
"GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size",
|
||||
"GOCHAT_GEOIP_DB_PATH": "geoip.db_path",
|
||||
// G10: OAuth config for new channel integrations (Twitter, Microsoft, Google)
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_ID": "oauth.twitter.client_id",
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_SECRET": "oauth.twitter.client_secret",
|
||||
@@ -837,6 +845,9 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("storage.local_path", "./uploads")
|
||||
v.SetDefault("storage.max_file_size", 20*1024*1024) // 20MB
|
||||
|
||||
// GeoIP defaults: empty path disables local lookup with a safe fallback.
|
||||
v.SetDefault("geoip.db_path", "")
|
||||
|
||||
v.SetDefault("copilot.provider_config", "")
|
||||
v.SetDefault("copilot.chat_api_key", "")
|
||||
v.SetDefault("copilot.embedding_api_key", "")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func TestSetDefaultsGeoIPPathIsOptional(t *testing.T) {
|
||||
v := viper.New()
|
||||
setDefaults(v)
|
||||
if got := v.GetString("geoip.db_path"); got != "" {
|
||||
t.Fatalf("geoip.db_path default = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package geoip provides the local IP-to-region lookup used for anonymous
|
||||
// Web Widget contacts. It deliberately has no network fallback: missing or
|
||||
// stale data must not make a public widget request fail.
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
maxmind "github.com/oschwald/geoip2-golang"
|
||||
)
|
||||
|
||||
// Location is the small region payload needed by the Web Widget name policy.
|
||||
type Location struct {
|
||||
Province string
|
||||
City string
|
||||
}
|
||||
|
||||
// Resolver is the service-facing GeoIP contract. Keeping the lookup boundary
|
||||
// small makes widget behavior testable without shipping a production MMDB file
|
||||
// in the repository.
|
||||
type Resolver interface {
|
||||
Lookup(net.IP) (Location, bool)
|
||||
}
|
||||
|
||||
// Reader wraps a MaxMind City database reader.
|
||||
type Reader struct {
|
||||
db *maxmind.Reader
|
||||
}
|
||||
|
||||
// Open opens a local MaxMind-compatible City database.
|
||||
func Open(path string) (*Reader, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := maxmind.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Reader{db: db}, nil
|
||||
}
|
||||
|
||||
// Lookup resolves a public IP to a province/city pair.
|
||||
func (r *Reader) Lookup(ip net.IP) (Location, bool) {
|
||||
if r == nil || r.db == nil || isNonPublicIP(ip) {
|
||||
return Location{}, false
|
||||
}
|
||||
|
||||
record, err := r.db.City(ip)
|
||||
if err != nil {
|
||||
return Location{}, false
|
||||
}
|
||||
|
||||
province := ""
|
||||
if len(record.Subdivisions) > 0 {
|
||||
province = localizedName(record.Subdivisions[0].Names)
|
||||
}
|
||||
return Location{
|
||||
Province: province,
|
||||
City: localizedName(record.City.Names),
|
||||
}, true
|
||||
}
|
||||
|
||||
// Close releases the underlying MMDB file descriptor.
|
||||
func (r *Reader) Close() error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
func isNonPublicIP(ip net.IP) bool {
|
||||
return ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast()
|
||||
}
|
||||
|
||||
func localizedName(names map[string]string) string {
|
||||
for _, locale := range []string{"zh-CN", "zh", "en"} {
|
||||
if name := strings.TrimSpace(names[locale]); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenEmptyPathDisablesLookup(t *testing.T) {
|
||||
reader, err := Open(" ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reader != nil {
|
||||
t.Fatalf("expected no reader for an empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMissingDatabaseReturnsError(t *testing.T) {
|
||||
if _, err := Open("/does/not/exist/GeoLite2-City.mmdb"); err == nil {
|
||||
t.Fatal("expected an error for a missing database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupRejectsNonPublicAddresses(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
for _, address := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "::1", "fc00::1"} {
|
||||
location, ok := reader.Lookup(net.ParseIP(address))
|
||||
if ok || location != (Location{}) {
|
||||
t.Fatalf("expected %s to be rejected, got %#v, %v", address, location, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedNamePrefersChineseThenEnglish(t *testing.T) {
|
||||
if got := localizedName(map[string]string{"en": "Hebei", "zh-CN": "河北"}); got != "河北" {
|
||||
t.Fatalf("got %q, want 河北", got)
|
||||
}
|
||||
if got := localizedName(map[string]string{"en": "Beijing"}); got != "Beijing" {
|
||||
t.Fatalf("got %q, want Beijing", got)
|
||||
}
|
||||
if got := localizedName(map[string]string{}); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gochat/gochat/internal/geoip"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type handlerGeoResolver struct {
|
||||
location geoip.Location
|
||||
seenIP net.IP
|
||||
}
|
||||
|
||||
func (r *handlerGeoResolver) Lookup(ip net.IP) (geoip.Location, bool) {
|
||||
r.seenIP = append(net.IP(nil), ip...)
|
||||
return r.location, true
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ConfigUsesServerClientIPForAnonymousName(t *testing.T) {
|
||||
db, router, handler := setupWidgetHandlerTest(t)
|
||||
_, _ = seedWidgetHandlerData(t, db)
|
||||
resolver := &handlerGeoResolver{location: geoip.Location{Province: "河北省", City: "保定市"}}
|
||||
handler.widgetService.SetVisitorGeoResolver(resolver)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(http.MethodPost, "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
|
||||
require.NoError(t, err)
|
||||
req.RemoteAddr = "203.0.113.20:4567"
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, "203.0.113.20", resolver.seenIP.String())
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
contact, ok := payload["contact"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "河北保定客户", contact["name"])
|
||||
}
|
||||
|
||||
func TestWidgetHandler_InitIgnoresClientSuppliedIP(t *testing.T) {
|
||||
db, router, handler := setupWidgetHandlerTest(t)
|
||||
_, _ = seedWidgetHandlerData(t, db)
|
||||
require.NoError(t, router.SetTrustedProxies(nil))
|
||||
resolver := &handlerGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}}
|
||||
handler.widgetService.SetVisitorGeoResolver(resolver)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(http.MethodPost, "/widget/init", strings.NewReader(`{"website_token":"handler_ws_token_123","client_ip":"198.51.100.99"}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.99")
|
||||
req.RemoteAddr = "203.0.113.21:4567"
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, "203.0.113.21", resolver.seenIP.String())
|
||||
var contact model.Contact
|
||||
require.NoError(t, db.First(&contact).Error)
|
||||
assert.Equal(t, "北京客户", contact.Name)
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ConfigHonorsConfiguredTrustedProxyIP(t *testing.T) {
|
||||
db, router, handler := setupWidgetHandlerTest(t)
|
||||
_, _ = seedWidgetHandlerData(t, db)
|
||||
require.NoError(t, router.SetTrustedProxies([]string{"203.0.113.0/24"}))
|
||||
resolver := &handlerGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}}
|
||||
handler.widgetService.SetVisitorGeoResolver(resolver)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(http.MethodPost, "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.44")
|
||||
req.RemoteAddr = "203.0.113.21:4567"
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, "198.51.100.44", resolver.seenIP.String())
|
||||
}
|
||||
|
||||
func TestWidgetContactFullPayloadDoesNotExposeVisitorMetadata(t *testing.T) {
|
||||
contact := &model.Contact{
|
||||
Name: "河北保定客户",
|
||||
AdditionalAttributes: datatypes.JSON(`{"visitor_name_source":"ip_geolocation","visitor_province":"河北","visitor_city":"保定","customer_note":"keep"}`),
|
||||
}
|
||||
payload := widgetContactFullPayload(contact)
|
||||
attributes, ok := payload["additional_attributes"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "keep", attributes["customer_note"])
|
||||
assert.NotContains(t, attributes, "visitor_name_source")
|
||||
assert.NotContains(t, attributes, "visitor_province")
|
||||
assert.NotContains(t, attributes, "visitor_city")
|
||||
}
|
||||
@@ -120,6 +120,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
if identifierHash == "" {
|
||||
identifierHash = c.Query("identifier_hash")
|
||||
}
|
||||
req.ClientIP = c.ClientIP()
|
||||
if req.Identifier != "" && identifierHash != "" {
|
||||
// Resolve the inbox to get its hmac_token for verification
|
||||
inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), req.WebsiteToken)
|
||||
@@ -159,6 +160,7 @@ func (h *WidgetHandler) Config(c *gin.Context) {
|
||||
if req.WidgetToken == "" {
|
||||
req.WidgetToken = widgetTokenFromRequest(c)
|
||||
}
|
||||
req.ClientIP = c.ClientIP()
|
||||
|
||||
resp, err := h.widgetService.Init(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
@@ -631,6 +633,7 @@ func (h *WidgetHandler) SetUser(c *gin.Context) {
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
CustomAttributes: req.CustomAttributes,
|
||||
AdditionalAttributes: req.AdditionalAttributes,
|
||||
ClientIP: c.ClientIP(),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
@@ -987,6 +990,7 @@ func (h *WidgetHandler) PublicCreateContact(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
req.ClientIP = c.ClientIP()
|
||||
resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
@@ -1010,6 +1014,7 @@ func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
req.ClientIP = c.ClientIP()
|
||||
resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
@@ -1205,6 +1210,7 @@ func (h *WidgetHandler) SubmitOfflineMessage(c *gin.Context) {
|
||||
|
||||
referer := c.Request.Referer()
|
||||
browserInfo := c.GetHeader("User-Agent")
|
||||
submission.ClientIP = c.ClientIP()
|
||||
|
||||
msg, err := h.widgetService.SubmitOfflineMessage(
|
||||
c.Request.Context(),
|
||||
@@ -1428,7 +1434,7 @@ func widgetContactFullPayload(contact *model.Contact) gin.H {
|
||||
"avatar_url": contact.AvatarURL,
|
||||
"identifier": contact.Identifier,
|
||||
"custom_attributes": contact.CustomAttributes,
|
||||
"additional_attributes": contact.AdditionalAttributes,
|
||||
"additional_attributes": service.PublicWidgetAdditionalAttributes(contact.AdditionalAttributes),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1548,7 +1548,7 @@ func TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK(t *testing.T
|
||||
|
||||
var activity model.Message
|
||||
require.NoError(t, db.Where("message_type = ?", string(model.MessageTypeActivity)).First(&activity).Error)
|
||||
assert.Equal(t, "Conversation was resolved by Anonymous Visitor", activity.Content)
|
||||
assert.Equal(t, "Conversation was resolved by 匿名客户", activity.Content)
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootToggleStatusHonorsEndConversationFlag(t *testing.T) {
|
||||
|
||||
@@ -15,20 +15,20 @@ import "time"
|
||||
// that agents can respond to when they return.
|
||||
type WidgetOfflineMessage struct {
|
||||
Base
|
||||
InboxID uint `gorm:"index;not null" json:"inbox_id"` // The web_widget inbox
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"` // Account scope for the message
|
||||
ContactName string `gorm:"size:255" json:"contact_name,omitempty"` // Visitor name (from pre-chat form or anonymous)
|
||||
ContactEmail string `gorm:"size:512" json:"contact_email,omitempty"` // Visitor email (from pre-chat form)
|
||||
ContactPhone string `gorm:"size:30" json:"contact_phone,omitempty"` // Visitor phone (from pre-chat form)
|
||||
ContactCompany string `gorm:"size:255" json:"contact_company,omitempty"` // Visitor company (from pre-chat form)
|
||||
ContactCity string `gorm:"size:255" json:"contact_city,omitempty"` // Visitor city (from pre-chat form)
|
||||
ContactCountry string `gorm:"size:255" json:"contact_country,omitempty"` // Visitor country (from pre-chat form)
|
||||
Content string `gorm:"type:text;not null" json:"content"` // The message content
|
||||
Referer string `gorm:"size:1024" json:"referer,omitempty"` // Page URL where widget was embedded
|
||||
BrowserInfo string `gorm:"size:512" json:"browser_info,omitempty"` // Browser metadata
|
||||
ConversationID *uint `gorm:"index" json:"conversation_id,omitempty"` // Set when converted to a conversation (null until then)
|
||||
Status OfflineStatus `gorm:"size:30;default:'pending'" json:"status"` // pending → converted → dismissed
|
||||
ConvertedAt *time.Time `json:"converted_at,omitempty"` // When the message was converted to a conversation
|
||||
InboxID uint `gorm:"index;not null" json:"inbox_id"` // The web_widget inbox
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"` // Account scope for the message
|
||||
ContactName string `gorm:"size:255" json:"contact_name,omitempty"` // Visitor name (from pre-chat form or anonymous)
|
||||
ContactEmail string `gorm:"size:512" json:"contact_email,omitempty"` // Visitor email (from pre-chat form)
|
||||
ContactPhone string `gorm:"size:30" json:"contact_phone,omitempty"` // Visitor phone (from pre-chat form)
|
||||
ContactCompany string `gorm:"size:255" json:"contact_company,omitempty"` // Visitor company (from pre-chat form)
|
||||
ContactCity string `gorm:"size:255" json:"contact_city,omitempty"` // Visitor city (from pre-chat form)
|
||||
ContactCountry string `gorm:"size:255" json:"contact_country,omitempty"` // Visitor country (from pre-chat form)
|
||||
Content string `gorm:"type:text;not null" json:"content"` // The message content
|
||||
Referer string `gorm:"size:1024" json:"referer,omitempty"` // Page URL where widget was embedded
|
||||
BrowserInfo string `gorm:"size:512" json:"browser_info,omitempty"` // Browser metadata
|
||||
ConversationID *uint `gorm:"index" json:"conversation_id,omitempty"` // Set when converted to a conversation (null until then)
|
||||
Status OfflineStatus `gorm:"size:30;default:'pending'" json:"status"` // pending → converted → dismissed
|
||||
ConvertedAt *time.Time `json:"converted_at,omitempty"` // When the message was converted to a conversation
|
||||
}
|
||||
|
||||
func (WidgetOfflineMessage) TableName() string { return "widget_offline_messages" }
|
||||
@@ -38,18 +38,19 @@ type OfflineStatus string
|
||||
|
||||
const (
|
||||
OfflineStatusPending OfflineStatus = "pending" // Awaiting agent to come online
|
||||
OfflineStatusConverted OfflineStatus = "converted" // Converted into a conversation
|
||||
OfflineStatusDismissed OfflineStatus = "dismissed" // Dismissed by admin without conversion
|
||||
OfflineStatusConverted OfflineStatus = "converted" // Converted into a conversation
|
||||
OfflineStatusDismissed OfflineStatus = "dismissed" // Dismissed by admin without conversion
|
||||
)
|
||||
|
||||
// WidgetOfflineMessageSubmission is the request body from the widget SDK
|
||||
// when a visitor submits a message while agents are offline.
|
||||
type WidgetOfflineMessageSubmission struct {
|
||||
Name string `json:"name,omitempty"` // Visitor name
|
||||
Email string `json:"email,omitempty"` // Visitor email
|
||||
Phone string `json:"phone,omitempty"` // Visitor phone
|
||||
Company string `json:"company,omitempty"` // Visitor company
|
||||
City string `json:"city,omitempty"` // Visitor city
|
||||
Country string `json:"country,omitempty"` // Visitor country
|
||||
Message string `json:"message" binding:"required"` // The offline message content
|
||||
}
|
||||
Name string `json:"name,omitempty"` // Visitor name
|
||||
Email string `json:"email,omitempty"` // Visitor email
|
||||
Phone string `json:"phone,omitempty"` // Visitor phone
|
||||
Company string `json:"company,omitempty"` // Visitor company
|
||||
City string `json:"city,omitempty"` // Visitor city
|
||||
Country string `json:"country,omitempty"` // Visitor country
|
||||
Message string `json:"message" binding:"required"` // The offline message content
|
||||
ClientIP string `json:"-"` // Set only by the trusted HTTP handler
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestWidgetService_SubmitOfflineMessage_MinimalFields(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, msg)
|
||||
assert.Equal(t, "Just a message, no contact info", msg.Content)
|
||||
assert.Empty(t, msg.ContactName)
|
||||
assert.Equal(t, "匿名客户", msg.ContactName)
|
||||
assert.Empty(t, msg.ContactEmail)
|
||||
assert.Empty(t, msg.ContactPhone)
|
||||
assert.Empty(t, msg.Referer)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/automation"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/geoip"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
@@ -69,6 +70,7 @@ type WidgetService struct {
|
||||
dispatcher *channel.Dispatcher
|
||||
realtime *wsevent.BridgeListener
|
||||
searchIndexer SearchIndexer
|
||||
visitorGeoResolver geoip.Resolver
|
||||
}
|
||||
|
||||
// NewWidgetService creates a new Widget service.
|
||||
@@ -125,6 +127,12 @@ func (s *WidgetService) SetSearchIndexer(indexer SearchIndexer) {
|
||||
s.searchIndexer = indexer
|
||||
}
|
||||
|
||||
// SetVisitorGeoResolver configures the local IP-to-region lookup used for
|
||||
// anonymous Web Widget contacts. A nil resolver keeps the safe fallback name.
|
||||
func (s *WidgetService) SetVisitorGeoResolver(resolver geoip.Resolver) {
|
||||
s.visitorGeoResolver = resolver
|
||||
}
|
||||
|
||||
func (s *WidgetService) indexConversation(ctx context.Context, conversation *model.Conversation) {
|
||||
if s.searchIndexer != nil && conversation != nil {
|
||||
logSearchIndexError("conversation", conversation.ID, s.searchIndexer.IndexConversation(ctx, conversation))
|
||||
@@ -150,6 +158,7 @@ type WidgetInitRequest struct {
|
||||
ContactPhone string `json:"contact_phone,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
HMACVerified bool `json:"hmac_verified,omitempty"` // true if client validated HMAC
|
||||
ClientIP string `json:"-"`
|
||||
}
|
||||
|
||||
// WidgetInitResponse is returned after successful widget init/auth.
|
||||
@@ -206,6 +215,7 @@ type WidgetSetUserRequest struct {
|
||||
PhoneNumber string
|
||||
CustomAttributes map[string]any
|
||||
AdditionalAttributes map[string]any
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
type WidgetSetUserResponse struct {
|
||||
@@ -231,6 +241,7 @@ type PublicContactRequest struct {
|
||||
PhoneNumber string
|
||||
CustomAttributes map[string]any
|
||||
AdditionalAttributes map[string]any
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
type PublicContactResponse struct {
|
||||
@@ -333,6 +344,12 @@ func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*Widge
|
||||
return nil, fmt.Errorf("failed to identify contact: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.ContactName) == "" {
|
||||
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to enrich anonymous contact: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Find or create ContactInbox
|
||||
if contactInbox == nil {
|
||||
@@ -828,10 +845,15 @@ func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, w
|
||||
}
|
||||
|
||||
func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.Contact, req WidgetContactUpdate) (*model.Contact, error) {
|
||||
if req.Name != "" {
|
||||
if !isGenericShangwutongName(req.Name) || isGenericShangwutongName(contact.Name) {
|
||||
contact.Name = req.Name
|
||||
if strings.TrimSpace(req.Name) != "" && (!isGenericShangwutongName(req.Name) || isGenericShangwutongName(contact.Name)) {
|
||||
contact.Name = req.Name
|
||||
attributes := jsonMap(contact.AdditionalAttributes)
|
||||
for key := range attributes {
|
||||
if isServerManagedVisitorAttribute(key) {
|
||||
delete(attributes, key)
|
||||
}
|
||||
}
|
||||
contact.AdditionalAttributes = mustJSON(attributes)
|
||||
}
|
||||
if req.Email != "" {
|
||||
contact.Email = req.Email
|
||||
@@ -855,6 +877,9 @@ func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.
|
||||
if len(req.AdditionalAttributes) > 0 {
|
||||
merged := jsonMap(contact.AdditionalAttributes)
|
||||
for k, v := range req.AdditionalAttributes {
|
||||
if isServerManagedVisitorAttribute(k) {
|
||||
continue
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
contact.AdditionalAttributes = mustJSON(merged)
|
||||
@@ -905,6 +930,12 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
existingInbox.Contact = *contact
|
||||
return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil
|
||||
}
|
||||
@@ -962,6 +993,12 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.IdentifierHash != "" && !contactInbox.HMACVerified {
|
||||
contactInbox.HMACVerified = true
|
||||
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
|
||||
@@ -1319,6 +1356,12 @@ func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !currentContactInbox.HMACVerified {
|
||||
currentContactInbox.HMACVerified = true
|
||||
if err := s.contactInboxRepo.Update(ctx, currentContactInbox); err != nil {
|
||||
@@ -1717,6 +1760,9 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
|
||||
if req.ContactEmail != "" {
|
||||
contact, err := s.contactRepo.FindByEmail(ctx, accountID, req.ContactEmail)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(req.ContactName) == "" {
|
||||
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
}
|
||||
@@ -1728,14 +1774,20 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
|
||||
}
|
||||
contacts, _, err := s.contactRepo.Search(ctx, accountID, searchQuery, 0, 5, "id ASC", search.SearchModeILike)
|
||||
if err == nil && len(contacts) > 0 {
|
||||
return &contacts[0], nil
|
||||
contact := &contacts[0]
|
||||
if strings.TrimSpace(req.ContactName) == "" {
|
||||
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create new contact
|
||||
name := req.ContactName
|
||||
name := strings.TrimSpace(req.ContactName)
|
||||
attributes := map[string]any{}
|
||||
if name == "" {
|
||||
name = "Anonymous Visitor"
|
||||
identity := s.anonymousVisitorIdentity(req.ClientIP)
|
||||
name = identity.name
|
||||
attributes = identity.attributes
|
||||
}
|
||||
|
||||
contact := model.Contact{
|
||||
@@ -1746,6 +1798,9 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
|
||||
Identifier: req.Identifier,
|
||||
ContactType: "visitor",
|
||||
}
|
||||
if len(attributes) > 0 {
|
||||
contact.AdditionalAttributes = mustJSON(attributes)
|
||||
}
|
||||
|
||||
if err := s.contactRepo.Create(ctx, &contact); err != nil {
|
||||
return nil, err
|
||||
@@ -1946,10 +2001,14 @@ func ParseWebWidgetConfig(channelConfig string) (*WebWidgetConfig, error) {
|
||||
// SubmitOfflineMessage stores a message from a visitor when agents are offline.
|
||||
// Returns the created offline message record.
|
||||
func (s *WidgetService) SubmitOfflineMessage(ctx context.Context, inboxID uint, accountID uint, submission *model.WidgetOfflineMessageSubmission, referer, browserInfo string) (*model.WidgetOfflineMessage, error) {
|
||||
contactName := strings.TrimSpace(submission.Name)
|
||||
if contactName == "" {
|
||||
contactName = s.anonymousVisitorIdentity(submission.ClientIP).name
|
||||
}
|
||||
msg := &model.WidgetOfflineMessage{
|
||||
InboxID: inboxID,
|
||||
AccountID: accountID,
|
||||
ContactName: submission.Name,
|
||||
ContactName: contactName,
|
||||
ContactEmail: submission.Email,
|
||||
ContactPhone: submission.Phone,
|
||||
ContactCompany: submission.Company,
|
||||
@@ -2128,17 +2187,33 @@ func splitWidgetLabels(raw string) []string {
|
||||
func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, req PublicContactRequest) (*model.Contact, error) {
|
||||
if req.Identifier != "" {
|
||||
if contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, req.Identifier); err == nil {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
}
|
||||
if req.Email != "" {
|
||||
if contact, err := s.contactRepo.FindByEmail(ctx, accountID, strings.ToLower(req.Email)); err == nil {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
attributes := filterWidgetAdditionalAttributes(req.AdditionalAttributes)
|
||||
if name == "" {
|
||||
identity := s.anonymousVisitorIdentity(req.ClientIP)
|
||||
name = identity.name
|
||||
for key, value := range identity.attributes {
|
||||
attributes[key] = value
|
||||
}
|
||||
}
|
||||
contact := &model.Contact{
|
||||
AccountID: accountID,
|
||||
Name: req.Name,
|
||||
Name: name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
AvatarURL: req.AvatarURL,
|
||||
@@ -2146,7 +2221,7 @@ func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, r
|
||||
SourceID: req.SourceID,
|
||||
ContactType: "visitor",
|
||||
CustomAttributes: mustJSON(req.CustomAttributes),
|
||||
AdditionalAttributes: mustJSON(req.AdditionalAttributes),
|
||||
AdditionalAttributes: mustJSON(attributes),
|
||||
}
|
||||
if err := s.contactRepo.Create(ctx, contact); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyAnonymousVisitorName = "Anonymous Visitor"
|
||||
anonymousVisitorName = "匿名客户"
|
||||
|
||||
visitorNameSourceKey = "visitor_name_source"
|
||||
visitorProvinceKey = "visitor_province"
|
||||
visitorCityKey = "visitor_city"
|
||||
visitorGeoAtKey = "visitor_geo_at"
|
||||
visitorNameSourceGeoIP = "ip_geolocation"
|
||||
visitorNameSourceFallback = "anonymous_fallback"
|
||||
)
|
||||
|
||||
type visitorIdentity struct {
|
||||
name string
|
||||
attributes map[string]any
|
||||
}
|
||||
|
||||
func (s *WidgetService) anonymousVisitorIdentity(clientIP string) visitorIdentity {
|
||||
identity := visitorIdentity{
|
||||
name: anonymousVisitorName,
|
||||
attributes: map[string]any{
|
||||
visitorNameSourceKey: visitorNameSourceFallback,
|
||||
},
|
||||
}
|
||||
if s.visitorGeoResolver == nil {
|
||||
return identity
|
||||
}
|
||||
|
||||
ip := net.ParseIP(strings.TrimSpace(clientIP))
|
||||
if ip == nil {
|
||||
return identity
|
||||
}
|
||||
location, ok := s.visitorGeoResolver.Lookup(ip)
|
||||
if !ok {
|
||||
return identity
|
||||
}
|
||||
name := formatVisitorName(location.Province, location.City)
|
||||
if name == "" {
|
||||
return identity
|
||||
}
|
||||
|
||||
identity.name = name
|
||||
identity.attributes = map[string]any{
|
||||
visitorNameSourceKey: visitorNameSourceGeoIP,
|
||||
visitorGeoAtKey: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if province := normalizeRegionPart(location.Province); province != "" {
|
||||
identity.attributes[visitorProvinceKey] = province
|
||||
}
|
||||
if city := normalizeRegionPart(location.City); city != "" {
|
||||
identity.attributes[visitorCityKey] = city
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func (s *WidgetService) enrichAnonymousWidgetContact(ctx context.Context, contact *model.Contact, clientIP string) (*model.Contact, error) {
|
||||
if contact == nil || !isAnonymousWidgetContact(contact) {
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
attributes := jsonMap(contact.AdditionalAttributes)
|
||||
if source, _ := attributes[visitorNameSourceKey].(string); source == visitorNameSourceGeoIP {
|
||||
return contact, nil
|
||||
}
|
||||
identity := s.anonymousVisitorIdentity(clientIP)
|
||||
if contact.Name == identity.name && reflect.DeepEqual(attributes[visitorNameSourceKey], identity.attributes[visitorNameSourceKey]) {
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
contact.Name = identity.name
|
||||
for key, value := range identity.attributes {
|
||||
attributes[key] = value
|
||||
}
|
||||
contact.AdditionalAttributes = mustJSON(attributes)
|
||||
if err := s.contactRepo.Update(ctx, contact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func isAnonymousWidgetContact(contact *model.Contact) bool {
|
||||
if contact == nil {
|
||||
return false
|
||||
}
|
||||
name := strings.TrimSpace(contact.Name)
|
||||
if name == "" || name == legacyAnonymousVisitorName || name == anonymousVisitorName {
|
||||
return true
|
||||
}
|
||||
attributes := jsonMap(contact.AdditionalAttributes)
|
||||
source, _ := attributes[visitorNameSourceKey].(string)
|
||||
if source == visitorNameSourceGeoIP {
|
||||
return true
|
||||
}
|
||||
return source == visitorNameSourceFallback && (name == "" || name == legacyAnonymousVisitorName || name == anonymousVisitorName)
|
||||
}
|
||||
|
||||
func formatVisitorName(province, city string) string {
|
||||
province = normalizeRegionPart(province)
|
||||
city = normalizeRegionPart(city)
|
||||
base := city
|
||||
if base == "" {
|
||||
base = province
|
||||
} else if province != "" && city != province && !strings.HasPrefix(city, province) {
|
||||
base = province + city
|
||||
}
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
return base + "客户"
|
||||
}
|
||||
|
||||
func normalizeRegionPart(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, suffix := range []string{"特别行政区", "自治区", "自治州", "省", "市", "地区", "盟"} {
|
||||
if strings.HasSuffix(value, suffix) && len([]rune(value)) > len([]rune(suffix)) {
|
||||
return strings.TrimSpace(strings.TrimSuffix(value, suffix))
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isServerManagedVisitorAttribute(key string) bool {
|
||||
switch key {
|
||||
case visitorNameSourceKey, visitorProvinceKey, visitorCityKey, visitorGeoAtKey, "created_at_ip":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func filterWidgetAdditionalAttributes(attributes map[string]any) map[string]any {
|
||||
filtered := make(map[string]any, len(attributes))
|
||||
for key, value := range attributes {
|
||||
if !isServerManagedVisitorAttribute(key) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// PublicWidgetAdditionalAttributes removes server-owned visitor metadata from
|
||||
// the public widget payload while retaining customer-provided attributes.
|
||||
func PublicWidgetAdditionalAttributes(raw datatypes.JSON) map[string]any {
|
||||
return filterWidgetAdditionalAttributes(jsonMap(raw))
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/gochat/gochat/internal/geoip"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeVisitorGeoResolver struct {
|
||||
location geoip.Location
|
||||
ok bool
|
||||
seenIP net.IP
|
||||
}
|
||||
|
||||
func (f *fakeVisitorGeoResolver) Lookup(ip net.IP) (geoip.Location, bool) {
|
||||
f.seenIP = append(net.IP(nil), ip...)
|
||||
return f.location, f.ok
|
||||
}
|
||||
|
||||
func TestFormatVisitorName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
province string
|
||||
city string
|
||||
want string
|
||||
}{
|
||||
{name: "province and city", province: "河北省", city: "保定市", want: "河北保定客户"},
|
||||
{name: "municipality", province: "北京市", city: "北京市", want: "北京客户"},
|
||||
{name: "province only", province: "河北省", want: "河北客户"},
|
||||
{name: "empty", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, formatVisitorName(tt.province, tt.city))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetService_InitUsesGeoIPNameWithoutPersistingIP(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
_, inbox := seedWidgetInbox(t, db)
|
||||
resolver := &fakeVisitorGeoResolver{
|
||||
location: geoip.Location{Province: "河北省", City: "保定市"},
|
||||
ok: true,
|
||||
}
|
||||
svc.SetVisitorGeoResolver(resolver)
|
||||
|
||||
resp, err := svc.Init(context.Background(), WidgetInitRequest{
|
||||
WebsiteToken: "test_ws_token_123",
|
||||
ClientIP: "203.0.113.10",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Contact)
|
||||
assert.Equal(t, inbox.ID, resp.InboxID)
|
||||
assert.Equal(t, "203.0.113.10", resolver.seenIP.String())
|
||||
assert.Equal(t, "河北保定客户", resp.Contact.Name)
|
||||
|
||||
var attributes map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Contact.AdditionalAttributes, &attributes))
|
||||
assert.Equal(t, visitorNameSourceGeoIP, attributes[visitorNameSourceKey])
|
||||
assert.Equal(t, "河北", attributes[visitorProvinceKey])
|
||||
assert.Equal(t, "保定", attributes[visitorCityKey])
|
||||
assert.NotContains(t, attributes, "client_ip")
|
||||
assert.NotContains(t, attributes, "created_at_ip")
|
||||
}
|
||||
|
||||
func TestWidgetService_InitFallsBackWhenGeoIPHasNoRecord(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
seedWidgetInbox(t, db)
|
||||
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{ok: false})
|
||||
|
||||
resp, err := svc.Init(context.Background(), WidgetInitRequest{
|
||||
WebsiteToken: "test_ws_token_123",
|
||||
ClientIP: "198.51.100.20",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, anonymousVisitorName, resp.Contact.Name)
|
||||
}
|
||||
|
||||
func TestWidgetService_InitPreservesRealName(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
seedWidgetInbox(t, db)
|
||||
resolver := &fakeVisitorGeoResolver{location: geoip.Location{Province: "河北", City: "保定"}, ok: true}
|
||||
svc.SetVisitorGeoResolver(resolver)
|
||||
|
||||
resp, err := svc.Init(context.Background(), WidgetInitRequest{
|
||||
WebsiteToken: "test_ws_token_123",
|
||||
ContactName: "张三",
|
||||
ClientIP: "203.0.113.11",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "张三", resp.Contact.Name)
|
||||
assert.Nil(t, resolver.seenIP)
|
||||
}
|
||||
|
||||
func TestWidgetService_InitUpgradesLegacyAnonymousName(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
account, _ := seedWidgetInbox(t, db)
|
||||
legacy := &model.Contact{
|
||||
AccountID: account.ID,
|
||||
Name: legacyAnonymousVisitorName,
|
||||
Email: "legacy@example.com",
|
||||
ContactType: "visitor",
|
||||
}
|
||||
require.NoError(t, db.Create(legacy).Error)
|
||||
resolver := &fakeVisitorGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}, ok: true}
|
||||
svc.SetVisitorGeoResolver(resolver)
|
||||
|
||||
resp, err := svc.Init(context.Background(), WidgetInitRequest{
|
||||
WebsiteToken: "test_ws_token_123",
|
||||
ContactEmail: "legacy@example.com",
|
||||
ClientIP: "203.0.113.12",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacy.ID, resp.ContactID)
|
||||
assert.Equal(t, "北京客户", resp.Contact.Name)
|
||||
}
|
||||
|
||||
func TestWidgetService_RealNameWinsOverGeneratedName(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
account, _ := seedWidgetInbox(t, db)
|
||||
contact := &model.Contact{
|
||||
AccountID: account.ID,
|
||||
Name: anonymousVisitorName,
|
||||
AdditionalAttributes: mustJSON(map[string]any{
|
||||
visitorNameSourceKey: visitorNameSourceFallback,
|
||||
}),
|
||||
}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
|
||||
location: geoip.Location{Province: "河北", City: "保定"},
|
||||
ok: true,
|
||||
})
|
||||
|
||||
updated, err := svc.updateContactFields(context.Background(), contact, WidgetContactUpdate{Name: "张三"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "张三", updated.Name)
|
||||
assert.Empty(t, jsonMap(updated.AdditionalAttributes)[visitorNameSourceKey])
|
||||
|
||||
updated, err = svc.enrichAnonymousWidgetContact(context.Background(), updated, "203.0.113.14")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "张三", updated.Name)
|
||||
}
|
||||
|
||||
func TestWidgetService_DoesNotOverwriteManuallyRenamedFallbackContact(t *testing.T) {
|
||||
_, svc := setupWidgetServiceTest(t)
|
||||
contact := &model.Contact{
|
||||
Name: "客服改名",
|
||||
AdditionalAttributes: mustJSON(map[string]any{
|
||||
visitorNameSourceKey: visitorNameSourceFallback,
|
||||
}),
|
||||
}
|
||||
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
|
||||
location: geoip.Location{Province: "河北", City: "保定"},
|
||||
ok: true,
|
||||
})
|
||||
|
||||
updated, err := svc.enrichAnonymousWidgetContact(context.Background(), contact, "203.0.113.15")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "客服改名", updated.Name)
|
||||
}
|
||||
|
||||
func TestWidgetService_ProtectsVisitorAttributesFromClientUpdates(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
account, _ := seedWidgetInbox(t, db)
|
||||
contact := &model.Contact{
|
||||
AccountID: account.ID,
|
||||
Name: "河北保定客户",
|
||||
AdditionalAttributes: mustJSON(map[string]any{
|
||||
visitorNameSourceKey: visitorNameSourceGeoIP,
|
||||
visitorProvinceKey: "河北",
|
||||
visitorCityKey: "保定",
|
||||
}),
|
||||
}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
|
||||
updated, err := svc.updateContactFields(context.Background(), contact, WidgetContactUpdate{
|
||||
AdditionalAttributes: map[string]any{
|
||||
visitorNameSourceKey: "spoofed",
|
||||
visitorProvinceKey: "北京",
|
||||
"customer_note": "kept",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
attributes := map[string]any{}
|
||||
require.NoError(t, json.Unmarshal(updated.AdditionalAttributes, &attributes))
|
||||
assert.Equal(t, visitorNameSourceGeoIP, attributes[visitorNameSourceKey])
|
||||
assert.Equal(t, "河北", attributes[visitorProvinceKey])
|
||||
assert.Equal(t, "kept", attributes["customer_note"])
|
||||
}
|
||||
|
||||
func TestWidgetService_PublicContactUsesGeoIPNameAndFiltersClientMetadata(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
account, _ := seedWidgetInbox(t, db)
|
||||
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
|
||||
location: geoip.Location{Province: "北京市", City: "北京市"},
|
||||
ok: true,
|
||||
})
|
||||
|
||||
contact, err := svc.findPublicContact(context.Background(), account.ID, PublicContactRequest{
|
||||
AdditionalAttributes: map[string]any{
|
||||
visitorNameSourceKey: "spoofed",
|
||||
"customer_note": "kept",
|
||||
},
|
||||
ClientIP: "203.0.113.16",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "北京客户", contact.Name)
|
||||
attributes := jsonMap(contact.AdditionalAttributes)
|
||||
assert.Equal(t, visitorNameSourceGeoIP, attributes[visitorNameSourceKey])
|
||||
assert.Equal(t, "kept", attributes["customer_note"])
|
||||
}
|
||||
|
||||
func TestPublicWidgetAdditionalAttributesHidesVisitorMetadata(t *testing.T) {
|
||||
public := PublicWidgetAdditionalAttributes(mustJSON(map[string]any{
|
||||
visitorNameSourceKey: visitorNameSourceGeoIP,
|
||||
visitorProvinceKey: "河北",
|
||||
"created_at_ip": "203.0.113.10",
|
||||
"customer_note": "kept",
|
||||
}))
|
||||
assert.Equal(t, map[string]any{"customer_note": "kept"}, public)
|
||||
}
|
||||
|
||||
func TestWidgetService_SubmitOfflineMessageUsesGeoIPName(t *testing.T) {
|
||||
db, svc := setupWidgetOfflineMessageTest(t)
|
||||
account := createTestAccountForWidget(t, db)
|
||||
inbox := createTestInboxForWidget(t, db, account.ID)
|
||||
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
|
||||
location: geoip.Location{Province: "河北省", City: "保定市"},
|
||||
ok: true,
|
||||
})
|
||||
|
||||
msg, err := svc.SubmitOfflineMessage(context.Background(), inbox.ID, account.ID, &model.WidgetOfflineMessageSubmission{
|
||||
Message: "Need help",
|
||||
ClientIP: "203.0.113.13",
|
||||
}, "", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "河北保定客户", msg.ContactName)
|
||||
}
|
||||
Reference in New Issue
Block a user